master-zapier-plan-draft/vendor/zapier-platform/packages/legacy-scripting-runner/index.js
George Lambert b4150c8250 Milestone 0: import zappier billing, Verae middleware, and Zapier research
Compose-ready workspace: packages/zappier (rate card, portal, Stripe),
packages/verae-zapier-middleware (timestamp + NATS), packages/verae-zapier
(CLI app), vendor/zapier-platform, and research/zapier vendor corpus.

Gate 0 structure checks pass. Product code and research are not yet wired.
2026-09-09 02:37:36 -04:00

1650 lines
47 KiB
JavaScript

const querystring = require('querystring');
const { promisify } = require('util');
const { URL } = require('url');
const _ = require('lodash');
const flatten = require('flat');
const FormData = require('form-data');
const {
recurseReplaceBank,
} = require('zapier-platform-core/src/tools/cleaner');
const { flattenPaths } = require('zapier-platform-core/src/tools/data');
const constants = require('zapier-platform-core/src/constants');
const {
ErrorException,
HaltedException,
StopRequestException,
ExpiredAuthException,
RefreshTokenException,
InvalidSessionException,
DEFINED_ERROR_NAMES,
} = require('./exceptions');
const createInternalRequestClient = (input) => {
const addQueryParams = require('zapier-platform-core/src/http-middlewares/before/add-query-params');
const createInjectInputMiddleware = require('zapier-platform-core/src/http-middlewares/before/inject-input');
const createRequestClient = require('zapier-platform-core/src/tools/create-request-client');
const disableSSLCertCheck = require('zapier-platform-core/src/http-middlewares/before/disable-ssl-cert-check');
const logResponseModule = require('zapier-platform-core/src/http-middlewares/after/log-response');
const prepareRequest = require('zapier-platform-core/src/http-middlewares/before/prepare-request');
const prepareResponse = require('zapier-platform-core/src/http-middlewares/after/prepare-response');
let sanitizeHeaders;
try {
sanitizeHeaders = require('zapier-platform-core/src/http-middlewares/before/sanatize-headers');
} catch (err) {
// Older versions of platform-core don't have sanitize-headers middleware
}
// Before core 12.0.3, log-response.js module exported the logResponse()
// function, and it's the only export. Since core 12.0.3, logResponse()
// function is inside an exported object. So we do the following to make sure
// legacy-scripting-runner works before and after 12.0.3.
// Related PR: https://github.com/zapier/zapier-platform/pull/525
const logResponse =
typeof logResponseModule === 'function'
? logResponseModule
: logResponseModule.logResponse;
const options = {
skipDefaultMiddle: true,
};
const httpBefores = [
createInjectInputMiddleware(input),
prepareRequest,
addQueryParams,
];
if (sanitizeHeaders) {
httpBefores.push(sanitizeHeaders);
}
const verifySSL = _.get(input, '_zapier.event.verifySSL');
if (verifySSL === false) {
httpBefores.push(disableSSLCertCheck);
}
const httpAfters = [prepareResponse, logResponse];
return createRequestClient(httpBefores, httpAfters, options);
};
const { bundleConverter, unflattenObject } = require('./bundle');
const {
markFileFieldsInBundle,
isAnyFileFieldSet,
isFileField,
LazyFile,
} = require('./file');
const {
createBeforeRequest,
createAfterResponse,
renderTemplate,
} = require('./middleware-factory');
const FIELD_TYPE_CONVERT_MAP = {
// field_type_in_wb: field_type_in_cli
bool: 'boolean',
copy: 'copy',
datetime: 'datetime',
dict: 'string',
file: 'file',
float: 'number',
int: 'integer',
password: 'password',
text: 'text',
unicode: 'string',
};
const cleanCustomFields = (fields) => {
return fields
.filter((field) => _.isPlainObject(field) && field.key)
.map((field) => {
if (field.type === 'dict') {
// For CLI, we set field.dict to true to represet a dict field instead
// of setting field.type to 'dict'
field.dict = true;
delete field.list;
}
field.type = FIELD_TYPE_CONVERT_MAP[field.type] || field.type;
return field;
});
};
const stringifyForFormData = (value) => {
const dataType = typeof value;
if (dataType === 'string') {
return value;
} else if (dataType === 'boolean') {
return value ? 'True' : 'False'; // mimic str(true) in Python
}
return JSON.stringify(value);
};
// Makes a multipart/form-data request body that can be set to request.body for
// node-fetch.
const makeMultipartForm = async (data, lazyFilesObject) => {
const form = new FormData();
if (data) {
if (_.isPlainObject(data)) {
_.each(data, (v, k) => {
if (v !== undefined && v !== null) {
form.append(k, stringifyForFormData(v));
}
});
} else {
if (typeof data !== 'string') {
data = JSON.stringify(data);
}
form.append('data', data);
}
}
const fileFieldKeys = Object.keys(lazyFilesObject);
const lazyFiles = Object.values(lazyFilesObject);
const fileMetas = await Promise.all(lazyFiles.map((f) => f.meta()));
const fileStreams = await Promise.all(lazyFiles.map((f) => f.readStream()));
_.zip(fileFieldKeys, fileMetas, fileStreams).forEach(
([k, meta, fileStream]) => {
form.append(k, fileStream, meta);
},
);
return form;
};
// Prepares request body from results.files and assign it to result.body. This
// accepts the request object returned by a KEY_pre_ method.
const addFilesToRequestBodyFromPreResult = async (request, event) => {
const originalHydrateUrls = _.map(event.originalFiles, (file, k) => file[1]);
const lazyFiles = _.reduce(
request.files,
(result, file, k) => {
let lazyFile;
if (Array.isArray(file) && file.length === 3) {
const [filename, newFileValue, contentType] = file;
// If pre_write changes the hydrate URL, file[1], we take it as a
// string content even if it looks like a URL
const loadUrl = originalHydrateUrls.includes(newFileValue);
lazyFile = LazyFile(
newFileValue,
{ filename, contentType },
{ dontLoadUrl: !loadUrl },
);
} else if (typeof file === 'string') {
lazyFile = LazyFile(file);
}
if (lazyFile) {
result[k] = lazyFile;
}
return result;
},
{},
);
delete request.headers['Content-Type'];
const form = await makeMultipartForm(request.data || '{}', lazyFiles);
request.body = form;
request.headers = { ...request.headers, ...form.getHeaders() };
return request;
};
// Reformats request.body into multipart/form-data for file upload. This
// accepts the CLI's bundle.request object.
const addFilesToRequestBodyFromBody = async (request, bundle) => {
const data = {};
const lazyFiles = {};
_.each(request.body, (v, k) => {
if (!isFileField(k, bundle)) {
data[k] = v;
} else if (typeof v === 'string') {
lazyFiles[k] = LazyFile(v);
}
});
delete request.headers['Content-Type'];
const form = await makeMultipartForm(JSON.stringify(data), lazyFiles);
request.body = form;
request.headers.Accept = '*/*';
request.headers = { ...request.headers, ...form.getHeaders() };
return request;
};
const pythonSerialize = (value) => {
if (typeof value === 'boolean') {
return value ? 'True' : 'False';
}
return value.toString();
};
const encodeFormData = (data) => {
const parts = Object.entries(data).reduce((result, [name, value]) => {
let newItems;
if (Array.isArray(value)) {
newItems = value;
} else if (_.isPlainObject(value)) {
newItems = Object.keys(value);
} else if (value != null) {
newItems = [value];
}
if (newItems) {
newItems = newItems
.filter((v) => v != null)
.map(pythonSerialize)
.map(encodeURIComponent)
.map((v) => `${name}=${v}`);
result.push(...newItems);
}
return result;
}, []);
return parts.join('&');
};
const parseFinalResult = async (result, event) => {
if (event.name.endsWith('.pre')) {
if (!_.isEmpty(result.files)) {
return addFilesToRequestBodyFromPreResult(result, event);
}
if (result.data) {
if (_.isPlainObject(result.data) && !event.name.startsWith('auth.')) {
// When a pre_(poll|write|search) gives an object for request.data, the
// data will always be form-urlencoded even if content-type is json. If
// the developer wants a JSON-encoded body, they need to encode the data
// using JSON.stringify().
result.body = encodeFormData(result.data);
} else {
// Old request was .data (string), new is .body (object), which matters
// for _pre
try {
result.body = JSON.parse(result.data);
} catch (e) {
result.body = result.data;
}
}
} else if (result.data === null || result.data === '') {
result.body = '';
}
// request.data isn't really used by CLI's z.request()
delete result.data;
return result;
}
// Old writes accepted a list, but CLI doesn't anymore, which matters for _write and _post_write
if (event.name === 'create.write' || event.name === 'create.post') {
let resultObj;
if (Array.isArray(result) && result.length) {
resultObj = result[0];
} else if (!Array.isArray(result)) {
resultObj = result;
} else {
resultObj = {};
}
return resultObj;
}
if (
event.name.endsWith('.input') ||
event.name.endsWith('.output') ||
event.name.endsWith('.input.post') ||
event.name.endsWith('.output.post')
) {
if (Array.isArray(result)) {
result = cleanCustomFields(result);
} else {
result = [];
}
}
return result;
};
const serializeValueForCurlies = (value) => {
if (typeof value === 'boolean') {
return value ? 'True' : 'False';
} else if (Array.isArray(value)) {
return value.join(',');
} else if (_.isPlainObject(value)) {
// Not sure if anyone would ever expect '[object Object]', but who knows?
return value.toString();
}
return value;
};
const createCurliesBank = (bundle, extra) => {
const bank = {
// This is for new curlies syntax, such as '{{bundle.inputData.var}}' and
// '{{bundle.authData.var}}'
bundle,
// These are for legacy curlies syntax without the 'bundle' prefix, such as
// {{var}}. The order matters.
...bundle.inputData,
...bundle.subscribeData,
...bundle.authData,
...extra,
};
const flattenedBank = flattenPaths(bank, {
perseve: {
'bundle.inputData': true,
},
});
return Object.entries(flattenedBank).reduce((coll, [key, value]) => {
coll[`{{${key}}}`] = serializeValueForCurlies(value);
return coll;
}, {});
};
const replaceCurliesInRequest = (request, bundle, extra) => {
const bank = createCurliesBank(bundle, extra);
return recurseReplaceBank(request, bank);
};
const cleanHeaders = (headers) => {
const newHeaders = {};
const badChars = /[\r\n\t]/g;
_.each(headers, (v, k) => {
if (k) {
k = k.replace(badChars, '').trim();
}
if (k) {
if (v && v.replace) {
v = v.replace(badChars, '').trim();
}
newHeaders[k] = v;
}
});
return newHeaders;
};
// Header keys are considered case insensitive. This function removes duplicate
// headers.
// dedupeHeaders({'x-key': 'one', 'X-KEY': 'two'}) returns {'x-key': 'two'}
const dedupeHeaders = (headers) => {
// lowerToFirstKeys stores the mapping from lowercased keys to the key that
// first appears in the headers
const lowerToFirstKeys = {};
const newHeaders = Object.entries(headers).reduce((result, [k, v]) => {
const lowerKey = k.toLowerCase();
if (lowerToFirstKeys[lowerKey] === undefined) {
lowerToFirstKeys[lowerKey] = k;
}
const firstKey = lowerToFirstKeys[lowerKey];
result[firstKey] = v;
return result;
}, {});
return newHeaders;
};
// Move all the query params from req.url to req.params. Make arrays if there
// are multiple values with the same name.
const mergeQueryParams = (req) => {
if (!req.url) {
return req;
}
const sepIndex = req.url.indexOf('?');
if (sepIndex < 0) {
return req;
}
// Extract the querystring. Don't use `new URL()` because the url might have
// curlies.
const querystring = req.url.substring(sepIndex + 1);
const searchParams = new URLSearchParams(querystring);
// Make an object like {name: ['alice', 'bob'], team: 'test'}
const params = Array.from(searchParams).reduce((result, [name, value]) => {
if (result[name] === undefined) {
result[name] = value;
return result;
}
if (!Array.isArray(result[name])) {
result[name] = [result[name]];
}
result[name].push(value);
return result;
}, {});
// Copy params collected from req.url to req.params
req.params = _.mergeWith(params, req.params, (dst, src) => {
if (src === null) {
// Legacy WB relies on Python serializing null, ugh
src = 'None';
}
if (dst === undefined) {
return src;
}
if (Array.isArray(dst)) {
if (Array.isArray(src)) {
return dst.concat(src);
} else {
dst.push(src);
return dst;
}
} else {
if (Array.isArray(src)) {
return [dst].concat(src);
} else {
return [dst, src];
}
}
});
// Remove query params from req.url as they all should be in req.params by now
req.url = req.url.substring(0, sepIndex);
return req;
};
const pruneQueryParams = (params) => {
// Only prune nulls and undefined's. Keep empty strings.
return Object.entries(params).reduce((result, [name, value]) => {
if (Array.isArray(value)) {
result[name] = value.filter((v) => v != null);
} else if (value != null) {
result[name] = value;
}
return result;
}, {});
};
const compileLegacyScriptingSource = (source, zcli, app, logger) => {
const { DOMParser, XMLSerializer } = require('@xmldom/xmldom');
const underscore = require('underscore');
// Configure template settings for legacy {{}} syntax
// Note: Using secure defaults to prevent code injection vulnerabilities
underscore.templateSettings = {
interpolate: /\{\{(.+?)\}\}/g,
evaluate: false, // Disable code evaluation for security
escape: false, // Keep legacy behavior but be explicit
};
// Restore legacy function names for backward compatibility with user scripts
// Based on test evidence in test/example-app/index.js lines 536-539
// _.collect was an alias for _.map in old underscore versions
if (!underscore.collect && underscore.map) {
underscore.collect = underscore.map;
}
// _.contains was renamed to _.includes in newer underscore versions
if (!underscore.contains && underscore.includes) {
underscore.contains = underscore.includes;
}
return new Function( // eslint-disable-line no-new-func
'_',
'crypto',
'async',
'moment',
'DOMParser',
'XMLSerializer',
'atob',
'btoa',
'z',
'$',
'console',
'require',
'ErrorException',
'HaltedException',
'StopRequestException',
'ExpiredAuthException',
'RefreshTokenException',
'InvalidSessionException',
source + '\nreturn Zap;',
)(
underscore,
require('crypto'),
require('async'),
require('moment-timezone'),
DOMParser,
XMLSerializer,
require('./atob'),
require('./btoa'),
require('./zfactory')(zcli, app, logger),
require('./$'),
zcli.console,
require,
ErrorException,
HaltedException,
StopRequestException,
ExpiredAuthException,
RefreshTokenException,
InvalidSessionException,
);
};
const applyBeforeMiddleware = (befores, request, z, bundle) => {
befores = befores || [];
return befores.reduce(
(prev, cur) => prev.then((req) => cur(req, z, bundle)),
Promise.resolve(request),
);
};
const createEventNameToMethodMapping = (key) => {
return {
//
// Auth
//
'auth.session': 'get_session_info',
'auth.connectionLabel': 'get_connection_label',
'auth.oauth2.token.pre': 'pre_oauthv2_token',
'auth.oauth2.token.post': 'post_oauthv2_token',
'auth.oauth2.refresh.pre': 'pre_oauthv2_refresh',
//
// Triggers
//
'trigger.poll': `${key}_poll`,
'trigger.pre': `${key}_pre_poll`,
'trigger.post': `${key}_post_poll`,
'trigger.hook': `${key}_catch_hook`,
'trigger.hook.pre': `${key}_pre_hook`,
'trigger.hook.post': `${key}_post_hook`,
'trigger.hook.subscribe.pre': 'pre_subscribe',
'trigger.hook.subscribe.post': 'post_subscribe',
'trigger.hook.unsubscribe.pre': 'pre_unsubscribe',
'trigger.output.pre': `${key}_pre_custom_trigger_fields`,
'trigger.output.post': `${key}_post_custom_trigger_fields`,
//
// Creates
//
'create.write': `${key}_write`,
'create.pre': `${key}_pre_write`,
'create.post': `${key}_post_write`,
'create.input': `${key}_custom_action_fields`,
'create.input.pre': `${key}_pre_custom_action_fields`,
'create.input.post': `${key}_post_custom_action_fields`,
'create.output': `${key}_custom_action_result_fields`,
'create.output.pre': `${key}_pre_custom_action_result_fields`,
'create.output.post': `${key}_post_custom_action_result_fields`,
//
// Searches
//
'search.search': `${key}_search`,
'search.pre': `${key}_pre_search`,
'search.post': `${key}_post_search`,
'search.resource': `${key}_read_resource`,
'search.resource.pre': `${key}_pre_read_resource`,
'search.resource.post': `${key}_post_read_resource`,
'search.input': `${key}_custom_search_fields`,
'search.input.pre': `${key}_pre_custom_search_fields`,
'search.input.post': `${key}_post_custom_search_fields`,
'search.output': `${key}_custom_search_result_fields`,
'search.output.pre': `${key}_pre_custom_search_result_fields`,
'search.output.post': `${key}_post_custom_search_result_fields`,
//
// Hydration
//
'hydrate.method': key,
};
};
const legacyScriptingRunner = (Zap, zcli, input) => {
const app = _.get(input, '_zapier.app');
const logger = _.get(input, '_zapier.logger');
if (typeof Zap === 'string') {
Zap = compileLegacyScriptingSource(Zap, zcli, app, logger);
}
const ensureIsType = (result, type) => {
if (!type) {
return result;
}
if (type.startsWith('array-')) {
if (Array.isArray(result)) {
return result;
} else if (result && typeof result === 'object') {
if (type === 'array-wrap') {
return [result];
} else {
// Find the first array in the response
for (const k in result) {
const value = result[k];
if (Array.isArray(value)) {
return value;
}
}
}
} else if (!result) {
return [];
}
throw new Error('JSON results array could not be located.');
} else if (type.startsWith('object-')) {
if (['number', 'string', 'boolean'].includes(typeof result)) {
// primitive types that aren't null or undefined
return { message: result };
} else if (_.isEmpty(result)) {
// null, undefined, empty object or array
return {};
} else if (_.isPlainObject(result)) {
return result;
} else if (
Array.isArray(result) &&
result.length > 0 &&
_.isPlainObject(result[0])
) {
// Used by auth test and auth label
return result[0];
}
throw new Error('JSON results object could not be located.');
}
return result;
};
/**
flattens trigger data for wb v1 apps
*/
const flattenTriggerData = (data) => {
for (const i in data) {
for (const j in data[i]) {
if (Array.isArray(data[i][j])) {
data[i][j] = textifyList(data[i][j]);
} else if (_.isPlainObject(data[i][j])) {
const flattened = flattenDictionary(j, data[i][j]);
data[i] = { ...data[i], ...flattened };
delete data[i][j];
}
}
}
return data;
};
/**
see handle_legacy_params in the python backend
*/
const handleLegacyParams = (data) => {
if (!_.isPlainObject(data)) {
return data;
}
const params = _.cloneDeep(data);
for (const i in data) {
if (_.isPlainObject(data[i])) {
const param = [];
for (const j in data[i]) {
param.push(j + '|' + data[i][j]);
}
params[i] = param.join('\n');
} else if (Array.isArray(data[i])) {
params[i] = data[i].join();
}
}
return params;
};
/**
see textify_list in the python backend
*/
const textifyList = (data) => {
if (!Array.isArray(data)) {
return data;
}
let out = '';
const allObj = data.every(_.isPlainObject);
const sep = allObj ? '\n' : ',';
for (const el of data) {
if (_.isPlainObject(el)) {
for (const key in el) {
out += `${key}: ${el[key]}${sep}`;
}
} else if (Array.isArray(el)) {
out += textifyList(el) + sep;
} else if (el === null) {
continue;
} else {
out += el + sep;
}
if (allObj) {
out += sep;
}
}
return out.replace(/[\n,]*$/, '');
};
const flattenDictionary = (prefix, data) => {
const flattenedData = flatten(_.cloneDeep(data), { delimiter: '__' });
const out = {};
for (const key in flattenedData) {
out[`${prefix}__${key}`] = flattenedData[key];
}
return out;
};
const chooseBetterError = (responseError, scriptError) => {
if (scriptError) {
if (!responseError || DEFINED_ERROR_NAMES.includes(scriptError.name)) {
return scriptError;
}
}
return responseError;
};
const runEvent = async (event, z, bundle) => {
if (!Zap || _.isEmpty(Zap) || !event || !event.name || !z) {
return;
}
const eventNameToMethod = createEventNameToMethodMapping(event.key);
const methodName = eventNameToMethod[event.name];
if (!methodName || !_.isFunction(Zap[methodName])) {
return;
}
const convertedBundle = await bundleConverter(bundle, event, z);
// In case the scripting method mutates the bundle
const bundleForLog = _.cloneDeep(convertedBundle);
// To know if request.files is changed by scripting
event.originalFiles = _.cloneDeep(
_.get(convertedBundle, 'request.files') || {},
);
const method = Zap[methodName].bind(Zap);
// method.length is the number of args method accepts
const isAsync = method.length > 1;
let result;
try {
if (isAsync) {
result = await promisify(method)(convertedBundle);
} else {
result = method(convertedBundle);
}
} catch (err) {
logger(`Errored calling legacy scripting ${methodName}`, {
log_type: 'bundle',
input: convertedBundle,
error_message: err.stack,
});
throw err;
}
logger(`Called legacy scripting ${methodName}`, {
log_type: 'bundle',
input: bundleForLog,
output: _.cloneDeep(result),
});
return parseFinalResult(result, event);
};
// Simulates how WB backend runs JS scripting methods
const runEventCombo = async (
bundle,
key,
preEventName,
postEventName,
fullEventName,
options,
) => {
options = {
// Options to deal with the final result returned by this function.
// * checkResponseStatus: throws an error if response status is not 2xx.
// * parseResponse:
// assumes response content is JSON and parse it. post method won't
// run if this is false.
// * ensureType: ensures the result type. Could be one of the following values:
// - false:
// returns whatever data parsed from response content or returned
// by the post method.
// - 'array-wrap': returns [result] if result is an object.
// - 'array-first':
// returns the first top-level array in the result if result
// is an object.
// - 'object-first': returns the first object if result is an array.
// * defaultToResponse: default to response if post method returns empty
checkResponseStatus: true,
parseResponse: true,
ensureType: false,
resetRequestForFullMethod: false,
defaultToResponse: false,
...options,
};
if (bundle.request) {
bundle.request = replaceCurliesInRequest(bundle.request, bundle);
}
if (_.get(app, 'legacy.needsTriggerData')) {
bundle.triggerData = {};
}
let result;
const eventNameToMethod = createEventNameToMethodMapping(key);
const preMethodName = preEventName ? eventNameToMethod[preEventName] : null;
const postMethodName = postEventName
? eventNameToMethod[postEventName]
: null;
const fullMethodName = fullEventName
? eventNameToMethod[fullEventName]
: null;
const fullMethod = fullMethodName ? Zap[fullMethodName] : null;
if (fullMethod) {
if (options.resetRequestForFullMethod) {
// Used by custom fields
bundle.request = { ...bundle.request, method: 'GET', url: '' };
}
// Running "full" scripting method like KEY_poll
try {
result = await runEvent({ key, name: fullEventName }, zcli, bundle);
} catch (error) {
if (error.name === 'StopRequestError') {
return ensureIsType(null, options.ensureType);
}
throw error;
}
} else {
const preMethod = preMethodName ? Zap[preMethodName] : null;
let request;
if (preMethod) {
try {
request = await runEvent({ key, name: preEventName }, zcli, bundle);
} catch (error) {
if (error.name === 'StopRequestError') {
return ensureIsType(null, options.ensureType);
}
throw error;
}
} else {
request = {};
}
request = { ...bundle.request, ...request };
if (constants.REPLACE_CURLIES) {
// REPLACE_CURLIES is added in core v17.1.0
request[constants.REPLACE_CURLIES] = true;
} else {
// For core < v17.1.0
request.replace = true;
}
const isBodyStream = typeof _.get(request, 'body.pipe') === 'function';
if (!preMethod && !isBodyStream && isAnyFileFieldSet(bundle)) {
// Enter here only when there's no KEY_pre method. When a KEY_pre method
// is defined, addFilesToRequestBodyFromPreResult() already does the
// file handling, so we don't want to do it again here.
await addFilesToRequestBodyFromBody(request, bundle);
}
// encode everything, but retain curlies - those are replaced in z.request
// important to maintain case here; `new URL()` lowercases the hostname,
// which may contain a variable like '{{process.env.URL}}'
request.url = encodeURI(request.url)
.replace(/%25/g, '%') // fixes double encoding
.replace(/%7B/g, '{')
.replace(/%7D/g, '}');
request.headers = cleanHeaders(request.headers);
request.headers = dedupeHeaders(request.headers);
request.allowGetBody = true;
request.serializeValueForCurlies = serializeValueForCurlies;
request.skipThrowForStatus = true;
request = mergeQueryParams(request);
if (request.params) {
request.params = pruneQueryParams(request.params);
}
if (_.get(app, 'legacy.skipEncodingChars')) {
// skipEncodingChars is only supported on core ^9.7.2, ^11.3.2 and >=12.x.
// core versions that don't support it will just ignore it.
request.skipEncodingChars = app.legacy.skipEncodingChars;
}
const response = await zcli.request(request);
if (!options.parseResponse) {
if (options.checkResponseStatus) {
response.skipThrowForStatus = false;
response.throwForStatus();
}
return response;
}
bundle.request = request;
const postMethod = postMethodName ? Zap[postMethodName] : null;
if (postMethod) {
let scriptError, responseError;
try {
result = await runEvent(
{ key, name: postEventName, response },
zcli,
bundle,
);
} catch (error) {
scriptError = error;
}
if (options.checkResponseStatus) {
// Raising this error AFTER postMethod is executed allows devs to
// intercept and throw another error from postMethod
response.skipThrowForStatus = false;
try {
response.throwForStatus();
} catch (error) {
responseError = error;
}
}
if (responseError || scriptError) {
throw chooseBetterError(responseError, scriptError);
}
if (options.defaultToResponse && !result) {
try {
result = zcli.JSON.parse(response.content);
} catch {
result = {};
}
}
} else {
if (options.checkResponseStatus) {
response.skipThrowForStatus = false;
response.throwForStatus();
}
try {
result = zcli.JSON.parse(response.content);
if (postEventName) {
result = await parseFinalResult(result, {
key,
name: postEventName,
});
}
} catch {
result = {};
}
}
}
result = ensureIsType(result, options.ensureType);
return result;
};
const fetchOAuth1Token = async (url, authParams) => {
const response = await zcli.request({
method: 'POST',
url,
auth: authParams,
skipThrowForStatus: true,
});
response.skipThrowForStatus = false;
response.throwForStatus();
return querystring.parse(response.content);
};
const runOAuth1GetRequestToken = (bundle) => {
const url = _.get(
app,
'legacy.authentication.oauth1Config.requestTokenUrl',
);
const templateContext = { ...bundle.authData, ...bundle.inputData };
const consumerKey = renderTemplate(process.env.CLIENT_ID, templateContext);
const consumerSecret = renderTemplate(
process.env.CLIENT_SECRET,
templateContext,
);
return fetchOAuth1Token(url, {
oauth_consumer_key: consumerKey,
oauth_consumer_secret: consumerSecret,
oauth_signature_method: 'HMAC-SHA1',
oauth_callback: bundle.inputData.redirect_uri,
});
};
const runOAuth1AuthorizeUrl = (bundle) => {
let url = _.get(app, 'legacy.authentication.oauth1Config.authorizeUrl');
if (!url) {
return '';
}
url = replaceCurliesInRequest({ url }, bundle, bundle.inputData).url;
const urlObj = new URL(url);
if (!urlObj.searchParams.has('oauth_token')) {
urlObj.searchParams.set('oauth_token', bundle.inputData.oauth_token);
}
return urlObj.href;
};
const runOAuth1GetAccessToken = (bundle) => {
let url = _.get(app, 'legacy.authentication.oauth1Config.accessTokenUrl');
if (url) {
url = replaceCurliesInRequest({ url }, bundle, bundle.inputData).url;
}
const templateContext = { ...bundle.authData, ...bundle.inputData };
const consumerKey = renderTemplate(process.env.CLIENT_ID, templateContext);
const consumerSecret = renderTemplate(
process.env.CLIENT_SECRET,
templateContext,
);
return fetchOAuth1Token(url, {
oauth_consumer_key: consumerKey,
oauth_consumer_secret: consumerSecret,
oauth_token: bundle.inputData.oauth_token,
oauth_token_secret: bundle.inputData.oauth_token_secret,
oauth_verifier: bundle.inputData.oauth_verifier,
});
};
const runOAuth2AuthorizeUrl = (bundle) => {
let url = _.get(app, 'legacy.authentication.oauth2Config.authorizeUrl');
if (!url) {
return '';
}
url = replaceCurliesInRequest({ url }, bundle, bundle.inputData).url;
const urlObj = new URL(url);
if (!urlObj.searchParams.has('client_id')) {
const templateContext = { ...bundle.authData, ...bundle.inputData };
const clientId = renderTemplate(process.env.CLIENT_ID, templateContext);
urlObj.searchParams.set('client_id', clientId);
}
if (!urlObj.searchParams.has('response_type')) {
urlObj.searchParams.set('response_type', 'code');
}
urlObj.searchParams.set('redirect_uri', bundle.inputData.redirect_uri);
urlObj.searchParams.set('state', bundle.inputData.state);
urlObj.searchParams.sort();
return urlObj.href;
};
const runOAuth2GetAccessToken = async (bundle) => {
let url = _.get(app, 'legacy.authentication.oauth2Config.accessTokenUrl');
if (url) {
url = replaceCurliesInRequest({ url }, bundle, bundle.inputData).url;
}
const request = bundle.request;
request.method = 'POST';
request.url = url;
request.headers['Content-Type'] = 'application/x-www-form-urlencoded';
const origRequest = _.cloneDeep(request);
const templateContext = { ...bundle.authData, ...bundle.inputData };
const clientId = renderTemplate(process.env.CLIENT_ID, templateContext);
const clientSecret = renderTemplate(
process.env.CLIENT_SECRET,
templateContext,
);
const authFieldKeys = (_.get(app, 'authentication.fields') || []).map(
(f) => f.key,
);
authFieldKeys.push('_zapier_account_id');
// In CLI, it's bundle.inputData instead of bundle.authData that has
// yet-to-save (auth hasn't been saved) auth fields. In WB,
// bundle.auth_fields always has yet-to-save auth fields.
bundle.authData = {
..._.pick(bundle.inputData, authFieldKeys),
...bundle.authData,
};
const params = request.params;
params.code = bundle.inputData.code;
params.client_id = clientId;
params.client_secret = clientSecret;
params.redirect_uri = bundle.inputData.redirect_uri;
params.grant_type = 'authorization_code';
let result;
try {
result = await runEventCombo(
bundle,
'',
'auth.oauth2.token.pre',
'auth.oauth2.token.post',
undefined,
{ defaultToResponse: true },
);
} catch (err) {
if (err.name !== 'ResponseError' || Zap.pre_oauthv2_token) {
throw err;
}
bundle.request = origRequest;
const body = origRequest.body;
body.code = bundle.inputData.code;
body.client_id = clientId;
body.client_secret = clientSecret;
body.redirect_uri = bundle.inputData.redirect_uri;
body.grant_type = 'authorization_code';
result = await runEventCombo(
bundle,
'',
'auth.oauth2.token.pre',
'auth.oauth2.token.post',
undefined,
{ defaultToResponse: true },
);
}
return result;
};
const runOAuth2RefreshAccessToken = async (bundle) => {
const url = _.get(
app,
'legacy.authentication.oauth2Config.refreshTokenUrl',
);
const request = bundle.request;
request.method = 'POST';
request.url = url;
request.headers['Content-Type'] = 'application/x-www-form-urlencoded';
const origRequest = _.cloneDeep(request);
const templateContext = { ...bundle.authData, ...bundle.inputData };
const clientId = renderTemplate(process.env.CLIENT_ID, templateContext);
const clientSecret = renderTemplate(
process.env.CLIENT_SECRET,
templateContext,
);
const body = request.body;
body.client_id = clientId;
body.client_secret = clientSecret;
body.refresh_token = bundle.authData.refresh_token;
body.redirect_uri = bundle.inputData.redirect_uri;
body.grant_type = 'refresh_token';
let result;
try {
result = await runEventCombo(bundle, '', 'auth.oauth2.refresh.pre');
} catch (err) {
if (err.name !== 'ResponseError' || Zap.pre_oauthv2_refresh) {
throw err;
}
bundle.request = origRequest;
const params = origRequest.params;
params.client_id = clientId;
params.client_secret = clientSecret;
params.refresh_token = bundle.authData.refresh_token;
params.redirect_uri = bundle.inputData.redirect_uri;
params.grant_type = 'refresh_token';
result = await runEventCombo(bundle, '', 'auth.oauth2.refresh.pre');
}
return result;
};
const isTestingAuth = (bundle) => {
// For core < 8.0.0
if (
_.get(bundle, 'meta.test_poll') === true &&
_.get(bundle, 'meta.standard_poll') === false
) {
return true;
}
// For core >= 8.0.0
return _.get(bundle, 'meta.isTestingAuth');
};
const runTrigger = async (bundle, key) => {
const url = _.get(app, `legacy.triggers.${key}.operation.url`);
const needsFlattenedData = _.get(app, 'legacy.needsFlattenedData');
bundle.request.url = url;
// For auth test we want to make sure we return an object instead of an array
const ensureType = isTestingAuth(bundle) ? 'object-first' : 'array-first';
// Legacy WB doesn't check if trigger results have id
bundle.skipChecks = ['triggerHasId'];
if (needsFlattenedData) {
bundle.skipChecks.push('triggerIsArray');
}
if (url) {
bundle._legacyUrl = url;
}
let result = await runEventCombo(
bundle,
key,
'trigger.pre',
'trigger.post',
'trigger.poll',
{ ensureType },
);
if (needsFlattenedData) {
result = flattenTriggerData(result);
}
return result;
};
const runCatchHook = async (bundle, key) => {
const methodName = `${key}_catch_hook`;
let result;
if (Zap[methodName]) {
try {
result = await runEvent({ key, name: 'trigger.hook' }, zcli, bundle);
} catch (error) {
if (error.name === 'StopRequestError') {
return [];
}
throw error;
}
} else {
result = bundle.cleanedRequest;
}
if (!Array.isArray(result)) {
result = [result];
}
return result;
};
const runPrePostHook = (bundle, key) => {
return runEventCombo(bundle, key, 'trigger.hook.pre', 'trigger.hook.post');
};
const runHook = (bundle, key) => {
const hookType = _.get(app, `legacy.triggers.${key}.operation.hookType`);
const needsFlattenedData = _.get(app, 'legacy.needsFlattenedData');
let cleanedArray;
if (Array.isArray(bundle.cleanedRequest)) {
cleanedArray = bundle.cleanedRequest;
} else if (
bundle.cleanedRequest &&
typeof bundle.cleanedRequest === 'object'
) {
cleanedArray = [bundle.cleanedRequest];
}
const shouldRunPrePostHook =
hookType === 'notification' &&
cleanedArray &&
cleanedArray.every((x) => x.resource_url);
if (shouldRunPrePostHook) {
const promises = cleanedArray.map((obj) => {
const bund = _.cloneDeep(bundle);
bund.request.url = obj.resource_url;
return runPrePostHook(bund, key);
});
return Promise.all(promises).then((obj) => {
obj = _.flatten(obj);
if (needsFlattenedData) {
obj = flattenTriggerData(obj);
}
return obj;
});
}
let result = runCatchHook(bundle, key);
if (needsFlattenedData) {
result = flattenTriggerData(result);
}
return result;
};
const runHookSubscribe = (bundle, key) => {
const url = _.get(app, 'legacy.subscribeUrl');
const event = _.get(app, `legacy.triggers.${key}.operation.event`);
const request = bundle.request;
request.method = 'POST';
request.url = url;
const body = request.body;
body.subscription_url = bundle.targetUrl; // backward compatibility
body.target_url = bundle.targetUrl;
body.event = event;
bundle._legacyEvent = event;
return runEventCombo(
bundle,
key,
'trigger.hook.subscribe.pre',
'trigger.hook.subscribe.post',
);
};
const runHookUnsubscribe = (bundle, key) => {
const url = _.get(app, 'legacy.unsubscribeUrl');
const event = _.get(app, `legacy.triggers.${key}.operation.event`);
const request = bundle.request;
request.method = 'POST';
request.url = url;
const body = request.body;
body.subscription_url = bundle.targetUrl; // backward compatibility
body.target_url = bundle.targetUrl;
body.event = event;
bundle._legacyEvent = event;
return runEventCombo(
bundle,
key,
'trigger.hook.unsubscribe.pre',
undefined,
undefined,
{ parseResponse: false },
);
};
const runCustomFields = async (
bundle,
key,
typeOf,
url,
supportFullMethod = true,
) => {
let preEventName, postEventName, fullEventName;
if (url) {
preEventName = typeOf + '.pre';
postEventName = typeOf + '.post';
bundle.request.url = url;
// Provide bundle.raw_url and bundle.url_raw
bundle._legacyUrl = url;
}
if (supportFullMethod) {
fullEventName = typeOf;
}
bundle.request.method = 'GET';
const fields = await runEventCombo(
bundle,
key,
preEventName,
postEventName,
fullEventName,
{ ensureType: 'array-wrap', resetRequestForFullMethod: true },
);
return cleanCustomFields(fields);
};
const runTriggerOutputFields = (bundle, key) => {
const url = _.get(app, `legacy.triggers.${key}.operation.outputFieldsUrl`);
const needsFlattenedData = _.get(app, 'legacy.needsFlattenedData');
if (needsFlattenedData) {
bundle.inputData = handleLegacyParams(bundle.inputData);
}
return runCustomFields(bundle, key, 'trigger.output', url, false);
};
const runCreate = (bundle, key) => {
const legacyProps = _.get(app, `legacy.creates.${key}.operation`) || {};
const needsFlattenedData = _.get(app, 'legacy.needsFlattenedData');
const url = legacyProps.url;
// This represents the "Send to Action Endpoint URL in JSON body" checkbox.
// If unchecked, the action field will be in this array.
const fieldsExcludedFromBody = legacyProps.fieldsExcludedFromBody || [];
const inputFields =
_.get(app, `creates.${key}.operation.inputFields`) || [];
markFileFieldsInBundle(bundle, inputFields);
if (needsFlattenedData) {
bundle.inputData = handleLegacyParams(bundle.inputData);
}
const body = {};
_.each(bundle.inputData, (v, k) => {
if (fieldsExcludedFromBody.indexOf(k) === -1) {
body[k] = v;
}
});
unflattenObject(body);
bundle.request.method = 'POST';
bundle.request.url = url;
bundle.request.body = body;
if (url) {
bundle._legacyUrl = url;
}
bundle._fieldsExcludedFromBody = fieldsExcludedFromBody;
return runEventCombo(
bundle,
key,
'create.pre',
'create.post',
'create.write',
{ ensureType: 'object-first' },
);
};
const runCreateInputFields = (bundle, key) => {
const url = _.get(app, `legacy.creates.${key}.operation.inputFieldsUrl`);
const needsFlattenedData = _.get(app, 'legacy.needsFlattenedData');
if (url && needsFlattenedData) {
bundle.inputData = handleLegacyParams(bundle.inputData);
}
return runCustomFields(bundle, key, 'create.input', url);
};
const runCreateOutputFields = (bundle, key) => {
const url = _.get(app, `legacy.creates.${key}.operation.outputFieldsUrl`);
return runCustomFields(bundle, key, 'create.output', url);
};
const runSearch = (bundle, key) => {
const url = _.get(app, `legacy.searches.${key}.operation.url`);
bundle.request.url = url;
if (url) {
bundle._legacyUrl = url;
}
return runEventCombo(
bundle,
key,
'search.pre',
'search.post',
'search.search',
{ ensureType: 'array-first' },
);
};
const runSearchResource = (bundle, key) => {
const url = _.get(app, `legacy.searches.${key}.operation.resourceUrl`);
bundle.request.url = url;
return runEventCombo(
bundle,
key,
'search.resource.pre',
'search.resource.post',
'search.resource',
{ parseResponseForPostMethod: true, ensureType: 'object-first' },
);
};
const runSearchInputFields = (bundle, key) => {
const url = _.get(app, `legacy.searches.${key}.operation.inputFieldsUrl`);
return runCustomFields(bundle, key, 'search.input', url);
};
const runSearchOutputFields = (bundle, key) => {
const url = _.get(app, `legacy.searches.${key}.operation.outputFieldsUrl`);
return runCustomFields(bundle, key, 'search.output', url);
};
const runHydrateMethod = (bundle) => {
const methodName = bundle.inputData.method;
return runEvent({ name: 'hydrate.method', key: methodName }, zcli, bundle);
};
const runHydrateFile = (bundle) => {
const meta = bundle.inputData.meta || {};
// Legacy z.dehydrateFile(url, request, meta) behavior: if `request`
// argument is provided (not null nor undefined), the dev is responsible for
// doing auth themselves, so we use an internal request client to avoid
// running the app's http middlewares.
const request =
bundle.inputData.request == null
? zcli.request
: createInternalRequestClient(input);
const requestOptions = bundle.inputData.request || {};
requestOptions.url = bundle.inputData.url || requestOptions.url;
requestOptions.raw = true;
requestOptions.throwForStatus = true;
const filePromise = request(requestOptions);
return zcli.stashFile(filePromise, meta.length, meta.name);
};
// core exposes this function as z.legacyScripting.run() method that we can
// run legacy scripting easily like z.legacyScripting.run(bundle, 'trigger', 'KEY')
// in CLI to simulate how WB backend runs legacy scripting.
const run = async (bundle, typeOf, key) => {
let request = {
url: '',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json; charset=utf-8',
},
params: {},
body: {},
};
if (key) {
// KEY_pre_ scripting methods expect the request to have auth info, so
// before calling scripting, we add auth info by applying
// app.beforeRequest here. We only need to do it for KEY_pre_ methods -
// that means only when `key` exists.
request = await applyBeforeMiddleware(
app.beforeRequest,
request,
zcli,
bundle,
);
}
bundle.request = request;
switch (typeOf) {
case 'auth.session':
return runEvent({ name: 'auth.session' }, zcli, bundle);
case 'auth.connectionLabel':
return runEvent({ name: 'auth.connectionLabel' }, zcli, bundle);
case 'auth.oauth1.requestToken':
return runOAuth1GetRequestToken(bundle);
case 'auth.oauth1.authorize':
return runOAuth1AuthorizeUrl(bundle);
case 'auth.oauth1.accessToken':
return runOAuth1GetAccessToken(bundle);
case 'auth.oauth2.authorize':
return runOAuth2AuthorizeUrl(bundle);
case 'auth.oauth2.token':
return runOAuth2GetAccessToken(bundle);
case 'auth.oauth2.refresh':
return runOAuth2RefreshAccessToken(bundle);
case 'trigger':
return runTrigger(bundle, key);
case 'trigger.hook':
return runHook(bundle, key);
case 'trigger.hook.subscribe':
return runHookSubscribe(bundle, key);
case 'trigger.hook.unsubscribe':
return runHookUnsubscribe(bundle, key);
case 'trigger.output':
return runTriggerOutputFields(bundle, key);
case 'create':
return runCreate(bundle, key);
case 'create.input':
return runCreateInputFields(bundle, key);
case 'create.output':
return runCreateOutputFields(bundle, key);
case 'search':
return runSearch(bundle, key);
case 'search.resource':
return runSearchResource(bundle, key);
case 'search.input':
return runSearchInputFields(bundle, key);
case 'search.output':
return runSearchOutputFields(bundle, key);
case 'hydrate.method':
return runHydrateMethod(bundle);
case 'hydrate.file':
return runHydrateFile(bundle);
}
throw new Error(`unrecognizable typeOf '${typeOf}'`);
};
// Dynamically generate http middlewares based on auth config. The generated
// middleware could be a no-op if the auth doesn't require a middleware.
const beforeRequest = createBeforeRequest(app);
const afterResponse = createAfterResponse(app);
return {
afterResponse,
beforeRequest,
run,
runEvent,
};
};
module.exports = legacyScriptingRunner;