@octaviaflow/flow-rules 0.5.0 → 0.7.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.cjs +246 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +246 -0
- package/dist/rules/configSafety.d.ts +62 -0
- package/dist/rules/mappingFunctions.d.ts +56 -0
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -53,6 +53,9 @@ __export(exports_src, {
|
|
|
53
53
|
ForEachConfig: () => ForEachConfig,
|
|
54
54
|
JoinType: () => JoinType,
|
|
55
55
|
LogicalOperator: () => LogicalOperator,
|
|
56
|
+
MAPPING_FUNCTIONS: () => MAPPING_FUNCTIONS,
|
|
57
|
+
MAPPING_FUNCTION_SPECS: () => MAPPING_FUNCTION_SPECS,
|
|
58
|
+
MAX_STEP_CONFIG_BYTES: () => MAX_STEP_CONFIG_BYTES,
|
|
56
59
|
ManualTriggerConfig: () => ManualTriggerConfig,
|
|
57
60
|
NodeStatus: () => NodeStatus,
|
|
58
61
|
QueryMode: () => QueryMode,
|
|
@@ -61,11 +64,13 @@ __export(exports_src, {
|
|
|
61
64
|
SortDirection: () => SortDirection,
|
|
62
65
|
TRANSFORM_OPERATIONS: () => TRANSFORM_OPERATIONS,
|
|
63
66
|
TransformConfig: () => TransformConfig,
|
|
67
|
+
USER_FUNCTION_PREFIX: () => USER_FUNCTION_PREFIX,
|
|
64
68
|
VariablesConfig: () => VariablesConfig,
|
|
65
69
|
actionsRegistry: () => actionsRegistry,
|
|
66
70
|
buildPathOptions: () => buildPathOptions,
|
|
67
71
|
collectArrayPaths: () => collectArrayPaths,
|
|
68
72
|
collectArrayPathsFromSample: () => collectArrayPathsFromSample,
|
|
73
|
+
configErrors: () => configErrors,
|
|
69
74
|
emailDefinition: () => emailDefinition,
|
|
70
75
|
flattenNestedWorkflow: () => flattenNestedWorkflow,
|
|
71
76
|
generateConnectionsFromSteps: () => generateConnectionsFromSteps,
|
|
@@ -81,7 +86,9 @@ __export(exports_src, {
|
|
|
81
86
|
getNodeValidationStatus: () => getNodeValidationStatus,
|
|
82
87
|
getStorageProvider: () => getStorageProvider,
|
|
83
88
|
inferSchemaFromSample: () => inferSchemaFromSample,
|
|
89
|
+
isMappingFunction: () => isMappingFunction,
|
|
84
90
|
isQueryableConnector: () => isQueryableConnector,
|
|
91
|
+
mappingTransformFunctionName: () => mappingTransformFunctionName,
|
|
85
92
|
migrateDelayConfig: () => migrateDelayConfig,
|
|
86
93
|
queryDefinition: () => queryDefinition,
|
|
87
94
|
storageConfigKeys: () => storageConfigKeys,
|
|
@@ -91,6 +98,8 @@ __export(exports_src, {
|
|
|
91
98
|
validateEdge: () => validateEdge,
|
|
92
99
|
validateFlowData: () => validateFlowData,
|
|
93
100
|
validateNode: () => validateNode,
|
|
101
|
+
validateStepConfig: () => validateStepConfig,
|
|
102
|
+
validateStepConfigs: () => validateStepConfigs,
|
|
94
103
|
validateWorkflow: () => validateWorkflow
|
|
95
104
|
});
|
|
96
105
|
module.exports = __toCommonJS(exports_src);
|
|
@@ -4093,3 +4102,240 @@ function validateFlowData(flow) {
|
|
|
4093
4102
|
const f = flow;
|
|
4094
4103
|
return typeof f.id === "string" && typeof f.name === "string" && f.workflow !== undefined && typeof f.workflow === "object" && f.workflow !== null && Array.isArray(f.workflow.steps);
|
|
4095
4104
|
}
|
|
4105
|
+
// src/rules/configSafety.ts
|
|
4106
|
+
var MAX_STEP_CONFIG_BYTES = 256 * 1024;
|
|
4107
|
+
var FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
4108
|
+
var MAX_DEPTH = 12;
|
|
4109
|
+
function typeMatches(field, value) {
|
|
4110
|
+
if (value === null || value === undefined || value === "")
|
|
4111
|
+
return true;
|
|
4112
|
+
switch (field.type) {
|
|
4113
|
+
case "number" /* Number */:
|
|
4114
|
+
return typeof value === "number" || typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value));
|
|
4115
|
+
case "boolean" /* Boolean */:
|
|
4116
|
+
return typeof value === "boolean" || value === "true" || value === "false";
|
|
4117
|
+
case "array" /* Array */:
|
|
4118
|
+
case "multi_select" /* MultiSelect */:
|
|
4119
|
+
return Array.isArray(value);
|
|
4120
|
+
case "object" /* Object */:
|
|
4121
|
+
case "mapping" /* Mapping */:
|
|
4122
|
+
return typeof value === "object" && !Array.isArray(value);
|
|
4123
|
+
case "string" /* String */:
|
|
4124
|
+
case "code" /* Code */:
|
|
4125
|
+
case "expression" /* Expression */:
|
|
4126
|
+
case "select" /* Select */:
|
|
4127
|
+
case "connection" /* Connection */:
|
|
4128
|
+
case "secret" /* Secret */:
|
|
4129
|
+
case "url" /* URL */:
|
|
4130
|
+
case "email" /* Email */:
|
|
4131
|
+
case "phone" /* Phone */:
|
|
4132
|
+
case "color" /* Color */:
|
|
4133
|
+
case "rich_text" /* RichText */:
|
|
4134
|
+
case "markdown" /* Markdown */:
|
|
4135
|
+
case "date" /* Date */:
|
|
4136
|
+
case "datetime" /* DateTime */:
|
|
4137
|
+
return typeof value !== "object";
|
|
4138
|
+
default:
|
|
4139
|
+
return true;
|
|
4140
|
+
}
|
|
4141
|
+
}
|
|
4142
|
+
function walk(value, stepId, path, depth, issues) {
|
|
4143
|
+
if (depth > MAX_DEPTH) {
|
|
4144
|
+
issues.push({
|
|
4145
|
+
severity: "error",
|
|
4146
|
+
code: "CONFIG_TOO_DEEP",
|
|
4147
|
+
stepId,
|
|
4148
|
+
field: path || undefined,
|
|
4149
|
+
message: `Configuration nests deeper than ${MAX_DEPTH} levels.`
|
|
4150
|
+
});
|
|
4151
|
+
return;
|
|
4152
|
+
}
|
|
4153
|
+
if (Array.isArray(value)) {
|
|
4154
|
+
for (const item of value)
|
|
4155
|
+
walk(item, stepId, path, depth + 1, issues);
|
|
4156
|
+
return;
|
|
4157
|
+
}
|
|
4158
|
+
if (value === null || typeof value !== "object")
|
|
4159
|
+
return;
|
|
4160
|
+
for (const key of Object.keys(value)) {
|
|
4161
|
+
if (FORBIDDEN_KEYS.has(key)) {
|
|
4162
|
+
issues.push({
|
|
4163
|
+
severity: "error",
|
|
4164
|
+
code: "FORBIDDEN_KEY",
|
|
4165
|
+
stepId,
|
|
4166
|
+
field: path ? `${path}.${key}` : key,
|
|
4167
|
+
message: `\`${key}\` is not allowed in a step's configuration.`
|
|
4168
|
+
});
|
|
4169
|
+
continue;
|
|
4170
|
+
}
|
|
4171
|
+
walk(value[key], stepId, path ? `${path}.${key}` : key, depth + 1, issues);
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
4174
|
+
function validateStepConfig(step) {
|
|
4175
|
+
const issues = [];
|
|
4176
|
+
const stepId = typeof step.id === "string" ? step.id : "(unknown step)";
|
|
4177
|
+
const config = step.config;
|
|
4178
|
+
if (config === null || config === undefined)
|
|
4179
|
+
return issues;
|
|
4180
|
+
if (typeof config !== "object" || Array.isArray(config)) {
|
|
4181
|
+
issues.push({
|
|
4182
|
+
severity: "error",
|
|
4183
|
+
code: "FIELD_WRONG_TYPE",
|
|
4184
|
+
stepId,
|
|
4185
|
+
message: "A step's configuration must be an object."
|
|
4186
|
+
});
|
|
4187
|
+
return issues;
|
|
4188
|
+
}
|
|
4189
|
+
let serialised;
|
|
4190
|
+
try {
|
|
4191
|
+
serialised = JSON.stringify(config);
|
|
4192
|
+
} catch {
|
|
4193
|
+
issues.push({
|
|
4194
|
+
severity: "error",
|
|
4195
|
+
code: "FIELD_WRONG_TYPE",
|
|
4196
|
+
stepId,
|
|
4197
|
+
message: "A step's configuration must be plain JSON."
|
|
4198
|
+
});
|
|
4199
|
+
return issues;
|
|
4200
|
+
}
|
|
4201
|
+
if (serialised.length > MAX_STEP_CONFIG_BYTES) {
|
|
4202
|
+
issues.push({
|
|
4203
|
+
severity: "error",
|
|
4204
|
+
code: "CONFIG_TOO_LARGE",
|
|
4205
|
+
stepId,
|
|
4206
|
+
message: `Configuration is ${Math.round(serialised.length / 1024)} KB; the limit is ${MAX_STEP_CONFIG_BYTES / 1024} KB.`
|
|
4207
|
+
});
|
|
4208
|
+
}
|
|
4209
|
+
walk(config, stepId, "", 0, issues);
|
|
4210
|
+
const definition = typeof step.actionDefinitionId === "string" ? getActionById(step.actionDefinitionId) : undefined;
|
|
4211
|
+
if (!definition)
|
|
4212
|
+
return issues;
|
|
4213
|
+
const declared = new Map(definition.inputSchema.fields.map((f) => [f.key, f]));
|
|
4214
|
+
const entries = Object.entries(config);
|
|
4215
|
+
for (const [key, value] of entries) {
|
|
4216
|
+
const field = declared.get(key);
|
|
4217
|
+
if (!field) {
|
|
4218
|
+
issues.push({
|
|
4219
|
+
severity: "warning",
|
|
4220
|
+
code: "UNDECLARED_FIELD",
|
|
4221
|
+
stepId,
|
|
4222
|
+
field: key,
|
|
4223
|
+
message: `\`${key}\` is not declared by ${definition.id}.`
|
|
4224
|
+
});
|
|
4225
|
+
continue;
|
|
4226
|
+
}
|
|
4227
|
+
if (!typeMatches(field, value)) {
|
|
4228
|
+
issues.push({
|
|
4229
|
+
severity: "error",
|
|
4230
|
+
code: "FIELD_WRONG_TYPE",
|
|
4231
|
+
stepId,
|
|
4232
|
+
field: key,
|
|
4233
|
+
message: `\`${key}\` must be ${field.type}.`
|
|
4234
|
+
});
|
|
4235
|
+
continue;
|
|
4236
|
+
}
|
|
4237
|
+
if (field.options && field.options.length > 0 && value !== null && value !== undefined && value !== "") {
|
|
4238
|
+
const allowed = new Set(field.options.map((o) => o.value));
|
|
4239
|
+
const values = Array.isArray(value) ? value : [value];
|
|
4240
|
+
const bad = values.filter((v) => (typeof v === "string" || typeof v === "number") && !allowed.has(v));
|
|
4241
|
+
if (bad.length > 0) {
|
|
4242
|
+
issues.push({
|
|
4243
|
+
severity: "warning",
|
|
4244
|
+
code: "FIELD_BAD_OPTION",
|
|
4245
|
+
stepId,
|
|
4246
|
+
field: key,
|
|
4247
|
+
message: `\`${key}\` must be one of: ${[...allowed].join(", ")}.`
|
|
4248
|
+
});
|
|
4249
|
+
}
|
|
4250
|
+
}
|
|
4251
|
+
}
|
|
4252
|
+
return issues;
|
|
4253
|
+
}
|
|
4254
|
+
function validateStepConfigs(steps) {
|
|
4255
|
+
return steps.flatMap((s) => validateStepConfig(s));
|
|
4256
|
+
}
|
|
4257
|
+
function configErrors(steps) {
|
|
4258
|
+
return validateStepConfigs(steps).filter((i) => i.severity === "error");
|
|
4259
|
+
}
|
|
4260
|
+
// src/rules/mappingFunctions.ts
|
|
4261
|
+
var str = (v) => String(v ?? "");
|
|
4262
|
+
var HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
4263
|
+
var MAPPING_FUNCTIONS = {
|
|
4264
|
+
concat: (...args) => args.map((a) => a == null ? "" : String(a)).join(""),
|
|
4265
|
+
uppercase: (val) => str(val).toUpperCase(),
|
|
4266
|
+
lowercase: (val) => str(val).toLowerCase(),
|
|
4267
|
+
trim: (val) => str(val).trim(),
|
|
4268
|
+
parseDate: (val) => {
|
|
4269
|
+
const d = new Date(String(val));
|
|
4270
|
+
return isNaN(d.getTime()) ? null : d.toISOString();
|
|
4271
|
+
},
|
|
4272
|
+
parseNumber: (val) => {
|
|
4273
|
+
const n = Number(val);
|
|
4274
|
+
return isNaN(n) ? null : n;
|
|
4275
|
+
},
|
|
4276
|
+
toString: (val) => str(val),
|
|
4277
|
+
substring: (val, start, end) => {
|
|
4278
|
+
const s = str(val);
|
|
4279
|
+
return s.substring(Number(start) || 0, end !== undefined ? Number(end) : undefined);
|
|
4280
|
+
},
|
|
4281
|
+
replace: (val, search, replacement) => str(val).replace(String(search), String(replacement)),
|
|
4282
|
+
default: (val, fallback) => val == null || val === "" ? fallback : val,
|
|
4283
|
+
normalizeUrl: (val, scheme = "https") => {
|
|
4284
|
+
const s = str(val).trim();
|
|
4285
|
+
if (!s)
|
|
4286
|
+
return "";
|
|
4287
|
+
if (HAS_SCHEME.test(s))
|
|
4288
|
+
return s;
|
|
4289
|
+
return `${String(scheme || "https")}://${s}`;
|
|
4290
|
+
},
|
|
4291
|
+
normalizeEmail: (val) => str(val).trim().toLowerCase(),
|
|
4292
|
+
normalizePhone: (val, countryCode) => {
|
|
4293
|
+
const raw = str(val).trim();
|
|
4294
|
+
if (!raw)
|
|
4295
|
+
return "";
|
|
4296
|
+
const plus = raw.startsWith("+");
|
|
4297
|
+
const digits = raw.replace(/\D/g, "");
|
|
4298
|
+
if (!digits)
|
|
4299
|
+
return "";
|
|
4300
|
+
if (plus)
|
|
4301
|
+
return `+${digits}`;
|
|
4302
|
+
const cc = str(countryCode).replace(/\D/g, "");
|
|
4303
|
+
return cc ? `+${cc}${digits}` : digits;
|
|
4304
|
+
},
|
|
4305
|
+
toDateOnly: (val) => {
|
|
4306
|
+
const d = new Date(String(val));
|
|
4307
|
+
return isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);
|
|
4308
|
+
}
|
|
4309
|
+
};
|
|
4310
|
+
var MAPPING_FUNCTION_SPECS = [
|
|
4311
|
+
{ name: "concat", signature: 'concat(value, "text", …)', description: "Joins the value and any literals into one string.", example: 'concat(value, " Ltd")' },
|
|
4312
|
+
{ name: "uppercase", signature: "uppercase(value)", description: "Upper-cases the value.", example: "uppercase(value)" },
|
|
4313
|
+
{ name: "lowercase", signature: "lowercase(value)", description: "Lower-cases the value.", example: "lowercase(value)" },
|
|
4314
|
+
{ name: "trim", signature: "trim(value)", description: "Removes leading and trailing whitespace.", example: "trim(value)" },
|
|
4315
|
+
{ name: "parseDate", signature: "parseDate(value)", description: "Parses to a full ISO timestamp, or null if unparseable.", example: "parseDate(value)" },
|
|
4316
|
+
{ name: "parseNumber", signature: "parseNumber(value)", description: "Parses to a number, or null if not numeric.", example: "parseNumber(value)" },
|
|
4317
|
+
{ name: "toString", signature: "toString(value)", description: 'Renders the value as a string ("" when empty).', example: "toString(value)" },
|
|
4318
|
+
{ name: "substring", signature: "substring(value, start, end?)", description: "Takes part of the value by index.", example: "substring(value, 0, 10)" },
|
|
4319
|
+
{ name: "replace", signature: 'replace(value, "find", "with")', description: "Replaces the first match.", example: 'replace(value, "Inc", "Inc.")' },
|
|
4320
|
+
{ name: "default", signature: 'default(value, "fallback")', description: "Uses the fallback when the value is empty or missing.", example: 'default(value, "Unknown")' },
|
|
4321
|
+
{ name: "normalizeUrl", signature: "normalizeUrl(value, scheme?)", description: "Adds https:// when the value names no scheme. Leaves http://, mailto: and others alone.", example: "normalizeUrl(value)" },
|
|
4322
|
+
{ name: "normalizeEmail", signature: "normalizeEmail(value)", description: "Trims and lower-cases an address.", example: "normalizeEmail(value)" },
|
|
4323
|
+
{ name: "normalizePhone", signature: "normalizePhone(value, countryCode?)", description: "Strips punctuation to digits, keeping a leading +. With a country code, prefixes numbers that lack one.", example: 'normalizePhone(value, "+1")' },
|
|
4324
|
+
{ name: "toDateOnly", signature: "toDateOnly(value)", description: "Reduces a timestamp to YYYY-MM-DD in UTC.", example: "toDateOnly(value)" }
|
|
4325
|
+
];
|
|
4326
|
+
var USER_FUNCTION_PREFIX = "user.";
|
|
4327
|
+
function isMappingFunction(name) {
|
|
4328
|
+
if (!name)
|
|
4329
|
+
return false;
|
|
4330
|
+
if (name.startsWith(USER_FUNCTION_PREFIX))
|
|
4331
|
+
return name.length > USER_FUNCTION_PREFIX.length;
|
|
4332
|
+
return Object.hasOwn(MAPPING_FUNCTIONS, name);
|
|
4333
|
+
}
|
|
4334
|
+
function mappingTransformFunctionName(transform) {
|
|
4335
|
+
const trimmed = (transform ?? "").trim();
|
|
4336
|
+
if (!trimmed)
|
|
4337
|
+
return null;
|
|
4338
|
+
const normalised = /^[A-Za-z_][\w.]*$/.test(trimmed) ? `${trimmed}(value)` : trimmed;
|
|
4339
|
+
const match = normalised.match(/^([A-Za-z_][\w.]*)\((.*)\)$/s);
|
|
4340
|
+
return match ? match[1] : null;
|
|
4341
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -43,3 +43,5 @@ export { type ManualTriggerConfigType } from "./catalog/triggers/manual-trigger/
|
|
|
43
43
|
export * from "./catalog/workflow";
|
|
44
44
|
export { type FlowConnection, type FlowData, type FlowStep, generateConnectionsFromSteps, transformConnectionToEdge, transformFlowToWorkflow, transformStepToNode, validateFlowData, } from "./rules/flowShape";
|
|
45
45
|
export { flattenNestedWorkflow } from "./rules/flattenNestedWorkflow";
|
|
46
|
+
export { configErrors, MAX_STEP_CONFIG_BYTES, validateStepConfig, validateStepConfigs, type ConfigIssue, type ConfigIssueCode, } from "./rules/configSafety";
|
|
47
|
+
export { isMappingFunction, MAPPING_FUNCTION_SPECS, MAPPING_FUNCTIONS, mappingTransformFunctionName, USER_FUNCTION_PREFIX, type MappingFunction, type MappingFunctionSpec, } from "./rules/mappingFunctions";
|
package/dist/index.js
CHANGED
|
@@ -3996,6 +3996,243 @@ function validateFlowData(flow) {
|
|
|
3996
3996
|
const f = flow;
|
|
3997
3997
|
return typeof f.id === "string" && typeof f.name === "string" && f.workflow !== undefined && typeof f.workflow === "object" && f.workflow !== null && Array.isArray(f.workflow.steps);
|
|
3998
3998
|
}
|
|
3999
|
+
// src/rules/configSafety.ts
|
|
4000
|
+
var MAX_STEP_CONFIG_BYTES = 256 * 1024;
|
|
4001
|
+
var FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
4002
|
+
var MAX_DEPTH = 12;
|
|
4003
|
+
function typeMatches(field, value) {
|
|
4004
|
+
if (value === null || value === undefined || value === "")
|
|
4005
|
+
return true;
|
|
4006
|
+
switch (field.type) {
|
|
4007
|
+
case "number" /* Number */:
|
|
4008
|
+
return typeof value === "number" || typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value));
|
|
4009
|
+
case "boolean" /* Boolean */:
|
|
4010
|
+
return typeof value === "boolean" || value === "true" || value === "false";
|
|
4011
|
+
case "array" /* Array */:
|
|
4012
|
+
case "multi_select" /* MultiSelect */:
|
|
4013
|
+
return Array.isArray(value);
|
|
4014
|
+
case "object" /* Object */:
|
|
4015
|
+
case "mapping" /* Mapping */:
|
|
4016
|
+
return typeof value === "object" && !Array.isArray(value);
|
|
4017
|
+
case "string" /* String */:
|
|
4018
|
+
case "code" /* Code */:
|
|
4019
|
+
case "expression" /* Expression */:
|
|
4020
|
+
case "select" /* Select */:
|
|
4021
|
+
case "connection" /* Connection */:
|
|
4022
|
+
case "secret" /* Secret */:
|
|
4023
|
+
case "url" /* URL */:
|
|
4024
|
+
case "email" /* Email */:
|
|
4025
|
+
case "phone" /* Phone */:
|
|
4026
|
+
case "color" /* Color */:
|
|
4027
|
+
case "rich_text" /* RichText */:
|
|
4028
|
+
case "markdown" /* Markdown */:
|
|
4029
|
+
case "date" /* Date */:
|
|
4030
|
+
case "datetime" /* DateTime */:
|
|
4031
|
+
return typeof value !== "object";
|
|
4032
|
+
default:
|
|
4033
|
+
return true;
|
|
4034
|
+
}
|
|
4035
|
+
}
|
|
4036
|
+
function walk(value, stepId, path, depth, issues) {
|
|
4037
|
+
if (depth > MAX_DEPTH) {
|
|
4038
|
+
issues.push({
|
|
4039
|
+
severity: "error",
|
|
4040
|
+
code: "CONFIG_TOO_DEEP",
|
|
4041
|
+
stepId,
|
|
4042
|
+
field: path || undefined,
|
|
4043
|
+
message: `Configuration nests deeper than ${MAX_DEPTH} levels.`
|
|
4044
|
+
});
|
|
4045
|
+
return;
|
|
4046
|
+
}
|
|
4047
|
+
if (Array.isArray(value)) {
|
|
4048
|
+
for (const item of value)
|
|
4049
|
+
walk(item, stepId, path, depth + 1, issues);
|
|
4050
|
+
return;
|
|
4051
|
+
}
|
|
4052
|
+
if (value === null || typeof value !== "object")
|
|
4053
|
+
return;
|
|
4054
|
+
for (const key of Object.keys(value)) {
|
|
4055
|
+
if (FORBIDDEN_KEYS.has(key)) {
|
|
4056
|
+
issues.push({
|
|
4057
|
+
severity: "error",
|
|
4058
|
+
code: "FORBIDDEN_KEY",
|
|
4059
|
+
stepId,
|
|
4060
|
+
field: path ? `${path}.${key}` : key,
|
|
4061
|
+
message: `\`${key}\` is not allowed in a step's configuration.`
|
|
4062
|
+
});
|
|
4063
|
+
continue;
|
|
4064
|
+
}
|
|
4065
|
+
walk(value[key], stepId, path ? `${path}.${key}` : key, depth + 1, issues);
|
|
4066
|
+
}
|
|
4067
|
+
}
|
|
4068
|
+
function validateStepConfig(step) {
|
|
4069
|
+
const issues = [];
|
|
4070
|
+
const stepId = typeof step.id === "string" ? step.id : "(unknown step)";
|
|
4071
|
+
const config = step.config;
|
|
4072
|
+
if (config === null || config === undefined)
|
|
4073
|
+
return issues;
|
|
4074
|
+
if (typeof config !== "object" || Array.isArray(config)) {
|
|
4075
|
+
issues.push({
|
|
4076
|
+
severity: "error",
|
|
4077
|
+
code: "FIELD_WRONG_TYPE",
|
|
4078
|
+
stepId,
|
|
4079
|
+
message: "A step's configuration must be an object."
|
|
4080
|
+
});
|
|
4081
|
+
return issues;
|
|
4082
|
+
}
|
|
4083
|
+
let serialised;
|
|
4084
|
+
try {
|
|
4085
|
+
serialised = JSON.stringify(config);
|
|
4086
|
+
} catch {
|
|
4087
|
+
issues.push({
|
|
4088
|
+
severity: "error",
|
|
4089
|
+
code: "FIELD_WRONG_TYPE",
|
|
4090
|
+
stepId,
|
|
4091
|
+
message: "A step's configuration must be plain JSON."
|
|
4092
|
+
});
|
|
4093
|
+
return issues;
|
|
4094
|
+
}
|
|
4095
|
+
if (serialised.length > MAX_STEP_CONFIG_BYTES) {
|
|
4096
|
+
issues.push({
|
|
4097
|
+
severity: "error",
|
|
4098
|
+
code: "CONFIG_TOO_LARGE",
|
|
4099
|
+
stepId,
|
|
4100
|
+
message: `Configuration is ${Math.round(serialised.length / 1024)} KB; the limit is ${MAX_STEP_CONFIG_BYTES / 1024} KB.`
|
|
4101
|
+
});
|
|
4102
|
+
}
|
|
4103
|
+
walk(config, stepId, "", 0, issues);
|
|
4104
|
+
const definition = typeof step.actionDefinitionId === "string" ? getActionById(step.actionDefinitionId) : undefined;
|
|
4105
|
+
if (!definition)
|
|
4106
|
+
return issues;
|
|
4107
|
+
const declared = new Map(definition.inputSchema.fields.map((f) => [f.key, f]));
|
|
4108
|
+
const entries = Object.entries(config);
|
|
4109
|
+
for (const [key, value] of entries) {
|
|
4110
|
+
const field = declared.get(key);
|
|
4111
|
+
if (!field) {
|
|
4112
|
+
issues.push({
|
|
4113
|
+
severity: "warning",
|
|
4114
|
+
code: "UNDECLARED_FIELD",
|
|
4115
|
+
stepId,
|
|
4116
|
+
field: key,
|
|
4117
|
+
message: `\`${key}\` is not declared by ${definition.id}.`
|
|
4118
|
+
});
|
|
4119
|
+
continue;
|
|
4120
|
+
}
|
|
4121
|
+
if (!typeMatches(field, value)) {
|
|
4122
|
+
issues.push({
|
|
4123
|
+
severity: "error",
|
|
4124
|
+
code: "FIELD_WRONG_TYPE",
|
|
4125
|
+
stepId,
|
|
4126
|
+
field: key,
|
|
4127
|
+
message: `\`${key}\` must be ${field.type}.`
|
|
4128
|
+
});
|
|
4129
|
+
continue;
|
|
4130
|
+
}
|
|
4131
|
+
if (field.options && field.options.length > 0 && value !== null && value !== undefined && value !== "") {
|
|
4132
|
+
const allowed = new Set(field.options.map((o) => o.value));
|
|
4133
|
+
const values = Array.isArray(value) ? value : [value];
|
|
4134
|
+
const bad = values.filter((v) => (typeof v === "string" || typeof v === "number") && !allowed.has(v));
|
|
4135
|
+
if (bad.length > 0) {
|
|
4136
|
+
issues.push({
|
|
4137
|
+
severity: "warning",
|
|
4138
|
+
code: "FIELD_BAD_OPTION",
|
|
4139
|
+
stepId,
|
|
4140
|
+
field: key,
|
|
4141
|
+
message: `\`${key}\` must be one of: ${[...allowed].join(", ")}.`
|
|
4142
|
+
});
|
|
4143
|
+
}
|
|
4144
|
+
}
|
|
4145
|
+
}
|
|
4146
|
+
return issues;
|
|
4147
|
+
}
|
|
4148
|
+
function validateStepConfigs(steps) {
|
|
4149
|
+
return steps.flatMap((s) => validateStepConfig(s));
|
|
4150
|
+
}
|
|
4151
|
+
function configErrors(steps) {
|
|
4152
|
+
return validateStepConfigs(steps).filter((i) => i.severity === "error");
|
|
4153
|
+
}
|
|
4154
|
+
// src/rules/mappingFunctions.ts
|
|
4155
|
+
var str = (v) => String(v ?? "");
|
|
4156
|
+
var HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
4157
|
+
var MAPPING_FUNCTIONS = {
|
|
4158
|
+
concat: (...args) => args.map((a) => a == null ? "" : String(a)).join(""),
|
|
4159
|
+
uppercase: (val) => str(val).toUpperCase(),
|
|
4160
|
+
lowercase: (val) => str(val).toLowerCase(),
|
|
4161
|
+
trim: (val) => str(val).trim(),
|
|
4162
|
+
parseDate: (val) => {
|
|
4163
|
+
const d = new Date(String(val));
|
|
4164
|
+
return isNaN(d.getTime()) ? null : d.toISOString();
|
|
4165
|
+
},
|
|
4166
|
+
parseNumber: (val) => {
|
|
4167
|
+
const n = Number(val);
|
|
4168
|
+
return isNaN(n) ? null : n;
|
|
4169
|
+
},
|
|
4170
|
+
toString: (val) => str(val),
|
|
4171
|
+
substring: (val, start, end) => {
|
|
4172
|
+
const s = str(val);
|
|
4173
|
+
return s.substring(Number(start) || 0, end !== undefined ? Number(end) : undefined);
|
|
4174
|
+
},
|
|
4175
|
+
replace: (val, search, replacement) => str(val).replace(String(search), String(replacement)),
|
|
4176
|
+
default: (val, fallback) => val == null || val === "" ? fallback : val,
|
|
4177
|
+
normalizeUrl: (val, scheme = "https") => {
|
|
4178
|
+
const s = str(val).trim();
|
|
4179
|
+
if (!s)
|
|
4180
|
+
return "";
|
|
4181
|
+
if (HAS_SCHEME.test(s))
|
|
4182
|
+
return s;
|
|
4183
|
+
return `${String(scheme || "https")}://${s}`;
|
|
4184
|
+
},
|
|
4185
|
+
normalizeEmail: (val) => str(val).trim().toLowerCase(),
|
|
4186
|
+
normalizePhone: (val, countryCode) => {
|
|
4187
|
+
const raw = str(val).trim();
|
|
4188
|
+
if (!raw)
|
|
4189
|
+
return "";
|
|
4190
|
+
const plus = raw.startsWith("+");
|
|
4191
|
+
const digits = raw.replace(/\D/g, "");
|
|
4192
|
+
if (!digits)
|
|
4193
|
+
return "";
|
|
4194
|
+
if (plus)
|
|
4195
|
+
return `+${digits}`;
|
|
4196
|
+
const cc = str(countryCode).replace(/\D/g, "");
|
|
4197
|
+
return cc ? `+${cc}${digits}` : digits;
|
|
4198
|
+
},
|
|
4199
|
+
toDateOnly: (val) => {
|
|
4200
|
+
const d = new Date(String(val));
|
|
4201
|
+
return isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);
|
|
4202
|
+
}
|
|
4203
|
+
};
|
|
4204
|
+
var MAPPING_FUNCTION_SPECS = [
|
|
4205
|
+
{ name: "concat", signature: 'concat(value, "text", …)', description: "Joins the value and any literals into one string.", example: 'concat(value, " Ltd")' },
|
|
4206
|
+
{ name: "uppercase", signature: "uppercase(value)", description: "Upper-cases the value.", example: "uppercase(value)" },
|
|
4207
|
+
{ name: "lowercase", signature: "lowercase(value)", description: "Lower-cases the value.", example: "lowercase(value)" },
|
|
4208
|
+
{ name: "trim", signature: "trim(value)", description: "Removes leading and trailing whitespace.", example: "trim(value)" },
|
|
4209
|
+
{ name: "parseDate", signature: "parseDate(value)", description: "Parses to a full ISO timestamp, or null if unparseable.", example: "parseDate(value)" },
|
|
4210
|
+
{ name: "parseNumber", signature: "parseNumber(value)", description: "Parses to a number, or null if not numeric.", example: "parseNumber(value)" },
|
|
4211
|
+
{ name: "toString", signature: "toString(value)", description: 'Renders the value as a string ("" when empty).', example: "toString(value)" },
|
|
4212
|
+
{ name: "substring", signature: "substring(value, start, end?)", description: "Takes part of the value by index.", example: "substring(value, 0, 10)" },
|
|
4213
|
+
{ name: "replace", signature: 'replace(value, "find", "with")', description: "Replaces the first match.", example: 'replace(value, "Inc", "Inc.")' },
|
|
4214
|
+
{ name: "default", signature: 'default(value, "fallback")', description: "Uses the fallback when the value is empty or missing.", example: 'default(value, "Unknown")' },
|
|
4215
|
+
{ name: "normalizeUrl", signature: "normalizeUrl(value, scheme?)", description: "Adds https:// when the value names no scheme. Leaves http://, mailto: and others alone.", example: "normalizeUrl(value)" },
|
|
4216
|
+
{ name: "normalizeEmail", signature: "normalizeEmail(value)", description: "Trims and lower-cases an address.", example: "normalizeEmail(value)" },
|
|
4217
|
+
{ name: "normalizePhone", signature: "normalizePhone(value, countryCode?)", description: "Strips punctuation to digits, keeping a leading +. With a country code, prefixes numbers that lack one.", example: 'normalizePhone(value, "+1")' },
|
|
4218
|
+
{ name: "toDateOnly", signature: "toDateOnly(value)", description: "Reduces a timestamp to YYYY-MM-DD in UTC.", example: "toDateOnly(value)" }
|
|
4219
|
+
];
|
|
4220
|
+
var USER_FUNCTION_PREFIX = "user.";
|
|
4221
|
+
function isMappingFunction(name) {
|
|
4222
|
+
if (!name)
|
|
4223
|
+
return false;
|
|
4224
|
+
if (name.startsWith(USER_FUNCTION_PREFIX))
|
|
4225
|
+
return name.length > USER_FUNCTION_PREFIX.length;
|
|
4226
|
+
return Object.hasOwn(MAPPING_FUNCTIONS, name);
|
|
4227
|
+
}
|
|
4228
|
+
function mappingTransformFunctionName(transform) {
|
|
4229
|
+
const trimmed = (transform ?? "").trim();
|
|
4230
|
+
if (!trimmed)
|
|
4231
|
+
return null;
|
|
4232
|
+
const normalised = /^[A-Za-z_][\w.]*$/.test(trimmed) ? `${trimmed}(value)` : trimmed;
|
|
4233
|
+
const match = normalised.match(/^([A-Za-z_][\w.]*)\((.*)\)$/s);
|
|
4234
|
+
return match ? match[1] : null;
|
|
4235
|
+
}
|
|
3999
4236
|
export {
|
|
4000
4237
|
ActionCategory,
|
|
4001
4238
|
ActionStatus,
|
|
@@ -4011,6 +4248,9 @@ export {
|
|
|
4011
4248
|
ForEachConfig,
|
|
4012
4249
|
JoinType,
|
|
4013
4250
|
LogicalOperator,
|
|
4251
|
+
MAPPING_FUNCTIONS,
|
|
4252
|
+
MAPPING_FUNCTION_SPECS,
|
|
4253
|
+
MAX_STEP_CONFIG_BYTES,
|
|
4014
4254
|
ManualTriggerConfig,
|
|
4015
4255
|
NodeStatus,
|
|
4016
4256
|
QueryMode,
|
|
@@ -4019,11 +4259,13 @@ export {
|
|
|
4019
4259
|
SortDirection,
|
|
4020
4260
|
TRANSFORM_OPERATIONS,
|
|
4021
4261
|
TransformConfig,
|
|
4262
|
+
USER_FUNCTION_PREFIX,
|
|
4022
4263
|
VariablesConfig,
|
|
4023
4264
|
actionsRegistry,
|
|
4024
4265
|
buildPathOptions,
|
|
4025
4266
|
collectArrayPaths,
|
|
4026
4267
|
collectArrayPathsFromSample,
|
|
4268
|
+
configErrors,
|
|
4027
4269
|
emailDefinition,
|
|
4028
4270
|
flattenNestedWorkflow,
|
|
4029
4271
|
generateConnectionsFromSteps,
|
|
@@ -4039,7 +4281,9 @@ export {
|
|
|
4039
4281
|
getNodeValidationStatus,
|
|
4040
4282
|
getStorageProvider,
|
|
4041
4283
|
inferSchemaFromSample,
|
|
4284
|
+
isMappingFunction,
|
|
4042
4285
|
isQueryableConnector,
|
|
4286
|
+
mappingTransformFunctionName,
|
|
4043
4287
|
migrateDelayConfig,
|
|
4044
4288
|
queryDefinition,
|
|
4045
4289
|
storageConfigKeys,
|
|
@@ -4049,5 +4293,7 @@ export {
|
|
|
4049
4293
|
validateEdge,
|
|
4050
4294
|
validateFlowData,
|
|
4051
4295
|
validateNode,
|
|
4296
|
+
validateStepConfig,
|
|
4297
|
+
validateStepConfigs,
|
|
4052
4298
|
validateWorkflow
|
|
4053
4299
|
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a step's config may contain, enforced where a caller cannot skip it.
|
|
3
|
+
*
|
|
4
|
+
* The editor's rules run in a browser, so they are advice, not a fence. A
|
|
5
|
+
* request posted straight at the API — or modified on its way — reaches
|
|
6
|
+
* `PUT /flows/:id/workflow`, which validated STRUCTURE (ids, connections,
|
|
7
|
+
* cycles) and typed config as `z.record(z.string(), z.unknown())`. Any key,
|
|
8
|
+
* any value, stored verbatim.
|
|
9
|
+
*
|
|
10
|
+
* WHAT THIS DELIBERATELY DOES NOT DO: treat `inputSchema.fields` as an
|
|
11
|
+
* allow-list. Measured against the real estate, 71 of 131 distinct config
|
|
12
|
+
* keys in use are not declared there — `slack_message.text`,
|
|
13
|
+
* `query.visualConfig`, `transform.operation`, `flowId` on nearly every
|
|
14
|
+
* action. `inputSchema` describes the fields the config PANEL renders, and
|
|
15
|
+
* every action with a custom panel stores more than that. Rejecting undeclared
|
|
16
|
+
* keys would refuse most flows on the platform. So an unknown key is a
|
|
17
|
+
* WARNING, never an error.
|
|
18
|
+
*
|
|
19
|
+
* What it does enforce is the part a legitimate client never violates:
|
|
20
|
+
*
|
|
21
|
+
* 1. a declared field's declared TYPE;
|
|
22
|
+
* 2. keys that can poison an object prototype;
|
|
23
|
+
* 3. a size and depth bound, because a config is a form's worth of settings
|
|
24
|
+
* and an unbounded one is a document every reader of that flow pays for
|
|
25
|
+
* (the DEV-366 problem, in a different field).
|
|
26
|
+
*
|
|
27
|
+
* Every one of those was dry-run against all 295 steps of the 77 flows on
|
|
28
|
+
* beta before it was allowed to block anything: 0 oversized (largest 28 KB
|
|
29
|
+
* against a 256 KB cap), 0 deeper than 6 (limit 12), 0 forbidden keys, 0 type
|
|
30
|
+
* mismatches. A rule that refuses data the platform already holds is not a
|
|
31
|
+
* fence, it is an outage.
|
|
32
|
+
*
|
|
33
|
+
* An enumerated field's options are checked but only WARN, for the same
|
|
34
|
+
* reason — see the note at that check.
|
|
35
|
+
*/
|
|
36
|
+
/** Largest config a single step may carry, serialised. */
|
|
37
|
+
export declare const MAX_STEP_CONFIG_BYTES: number;
|
|
38
|
+
export type ConfigIssueCode = "FORBIDDEN_KEY" | "CONFIG_TOO_LARGE" | "CONFIG_TOO_DEEP" | "FIELD_WRONG_TYPE" | "FIELD_BAD_OPTION" | "UNDECLARED_FIELD";
|
|
39
|
+
export interface ConfigIssue {
|
|
40
|
+
severity: "error" | "warning";
|
|
41
|
+
code: ConfigIssueCode;
|
|
42
|
+
stepId: string;
|
|
43
|
+
/** The config key, when the issue is about one. */
|
|
44
|
+
field?: string;
|
|
45
|
+
message: string;
|
|
46
|
+
}
|
|
47
|
+
export interface StepLike {
|
|
48
|
+
id?: unknown;
|
|
49
|
+
actionDefinitionId?: unknown;
|
|
50
|
+
config?: unknown;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Check one step's config.
|
|
54
|
+
*
|
|
55
|
+
* Errors block a save. Warnings never do — see the file header on why an
|
|
56
|
+
* undeclared key cannot be an error.
|
|
57
|
+
*/
|
|
58
|
+
export declare function validateStepConfig(step: StepLike): ConfigIssue[];
|
|
59
|
+
/** Every step of a workflow. `steps` may be nested; flatten before calling. */
|
|
60
|
+
export declare function validateStepConfigs(steps: readonly StepLike[]): ConfigIssue[];
|
|
61
|
+
/** Just the blocking ones — what a save path refuses on. */
|
|
62
|
+
export declare function configErrors(steps: readonly StepLike[]): ConfigIssue[];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The functions a Field Mapper `transform` may call — implementations AND the
|
|
3
|
+
* list, in one place.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. A Transform step's mapping has two slots, and they are not
|
|
6
|
+
* interchangeable:
|
|
7
|
+
*
|
|
8
|
+
* sourceField names a FIELD `{{Website}}`
|
|
9
|
+
* transform transforms the VALUE `uppercase(value)`
|
|
10
|
+
*
|
|
11
|
+
* Neither one evaluates an expression. `sourceField` is a literal path lookup,
|
|
12
|
+
* and `transform` is a single function call — so anything clever written in
|
|
13
|
+
* either resolves to nothing. Both failure modes were silent: a bad
|
|
14
|
+
* `sourceField` wrote an EMPTY value on every record, and an unknown
|
|
15
|
+
* `transform` function returned the source value unchanged, with a comment in
|
|
16
|
+
* the engine that said so out loud ("an unknown built-in falls through
|
|
17
|
+
* silently"). An agent hit the first one and blanked a field on every customer
|
|
18
|
+
* a live flow synced.
|
|
19
|
+
*
|
|
20
|
+
* Shipping the list without the implementations would just move the drift: the
|
|
21
|
+
* UI's FX sidebar already advertises 99 functions while the engine's mapping
|
|
22
|
+
* executor accepted 10, and four of those ten were not in the sidebar at all.
|
|
23
|
+
* So this module owns BOTH. One import, nothing to keep in step.
|
|
24
|
+
*
|
|
25
|
+
* ADDING ONE: put it in `MAPPING_FUNCTIONS` with a `MAPPING_FUNCTION_SPECS`
|
|
26
|
+
* entry. The spec is what the editor lists and what an agent is shown, so a
|
|
27
|
+
* function without one is invisible and effectively does not exist.
|
|
28
|
+
*/
|
|
29
|
+
/** A value-transform. `value` is the resolved source; the rest are literals. */
|
|
30
|
+
export type MappingFunction = (...args: unknown[]) => unknown;
|
|
31
|
+
export interface MappingFunctionSpec {
|
|
32
|
+
name: string;
|
|
33
|
+
/** How it is written, for the picker and for an agent's tool description. */
|
|
34
|
+
signature: string;
|
|
35
|
+
description: string;
|
|
36
|
+
example: string;
|
|
37
|
+
}
|
|
38
|
+
export declare const MAPPING_FUNCTIONS: Record<string, MappingFunction>;
|
|
39
|
+
export declare const MAPPING_FUNCTION_SPECS: readonly MappingFunctionSpec[];
|
|
40
|
+
/** Custom functions are written by the workspace and resolved at run time. */
|
|
41
|
+
export declare const USER_FUNCTION_PREFIX = "user.";
|
|
42
|
+
/**
|
|
43
|
+
* Is this a function a mapping may call?
|
|
44
|
+
*
|
|
45
|
+
* `user.*` is accepted on name alone — the workspace's own functions are
|
|
46
|
+
* resolved from a registry at run time, and this module cannot know them.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isMappingFunction(name: string): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The function a `transform` calls, or `null` when it is not a call at all.
|
|
51
|
+
*
|
|
52
|
+
* Mirrors the engine's parsing exactly, including the back-compat shim: a BARE
|
|
53
|
+
* name (`uppercase`) means `uppercase(value)`, because older saved mappings
|
|
54
|
+
* persisted just the name.
|
|
55
|
+
*/
|
|
56
|
+
export declare function mappingTransformFunctionName(transform: string): string | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octaviaflow/flow-rules",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "The flow action catalog and the rules engine behind Flow Doctor \u2014 one definition of what a step accepts and what makes a flow valid, shared by the editor and the server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|