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.
This commit is contained in:
George Lambert 2026-09-09 02:37:36 -04:00
commit b4150c8250
1364 changed files with 6814366 additions and 0 deletions

View file

@ -0,0 +1,34 @@
const jQuery = require('jquery');
const { DOMParser } = require('@xmldom/xmldom');
const { jsdom } = require('jsdom');
const window = jsdom().defaultView;
window.DOMParser = DOMParser;
const $ = jQuery(window);
$.parseXML = (data) => {
if (!data || typeof data !== 'string') {
return null;
}
let xml;
try {
xml = new DOMParser().parseFromString(data, 'text/xml');
} catch (e) {
// We can safely ignore this
}
if (
!xml ||
!xml.documentElement ||
xml.getElementsByTagName('parsererror').length
) {
throw new Error(`Invalid XML: ${data}`);
}
return xml;
};
module.exports = $;

View file

@ -0,0 +1,3 @@
node_modules
*.log
.DS_Store

View file

@ -0,0 +1 @@
v22

View file

@ -0,0 +1,26 @@
# `zapier-platform-legacy-scripting-runner` Architecture
## Purpose
- This package, released as `zapier-platform-legacy-scripting-runner` publicly on [npm](https://www.npmjs.com/package/zapier-platform-legacy-scripting-runner), is a compatibility shim between the legacy v2 platform and the current "v3" (aka CLI) platform.
- Most developers won't install this directly, but we'll include it when auto-converting their legacy applications.
- It implements everything found in the [legacy scripting environment](https://platform.zapier.com/legacy/scripting#available-libraries) ([github pinned link](https://github.com/zapier/visual-builder/blob/8da4359f0b67b7a343067fa84b74576917c85fdd/docs/_legacy/scripting.md) in case that done doesn't work).
- While `core`, `cli`, and `schema` are released in lockstep and always match versions, `legacy-scripting-runner` is versioned separately and released on its own.
## Technical Organization
> `runner/...`, when used in a path to a file, is shorthand for `zapier-platform/packages/legacy-scripting-runner/...`
### Important Functions
- The lion's share of code lives in `runner/index.js`.
- The primary purposes are to:
- make available global functions that were available in the legacy environment (see the `compileLegacyScriptingSource` function)
- handle `pre_X` and `post_X` lifecycle hooks (see `runEventCombo` function)
- ensure inputs and outputs continue to allow what was allowed in the legacy environment (while CLI apps may have more strict requirements, such as needing to return arrays from triggers)
- There are also individual files in `runner/*` that power functions/libraries exposed above, such as `btoa`, `$` (for `jQuery`), etc.
### Tests
- The main test file is `runner/test/integration-test.js`, which takes the basic example app from `runner/test/example-app/index.js`, tweaks a single trigger/action at a time, and makes asserts about how it performs.
- There are also unit tests for individually implemented functions (such as `btoa`) in `runner/test/libraries.js`.

View file

@ -0,0 +1,227 @@
## 4.0.8
- :hammer: Bump `@xmldom/xmldom` from 0.8.11 to 0.8.13 ([#1286](https://github.com/zapier/zapier-platform/pull/1286))
## 4.0.7
- :hammer: Bump `underscore` from 1.13.7 to 1.13.8 ([#1256](https://github.com/zapier/zapier-platform/pull/1256))
- :hammer: Bump `lodash` from 4.17.23 to 4.18.1 ([#1274](https://github.com/zapier/zapier-platform/pull/1274))
## 4.0.6
- :bug: Declare optional peer dependency to fix pnpm strict module resolution ([#1255](https://github.com/zapier/zapier-platform/pull/1255))
## 4.0.5
_Released 2026-02-26_
- :bug: Add explicit `form-data` dependency to work with pnpm strict module isolation ([#1247](https://github.com/zapier/zapier-platform/pull/1247))
- :hammer: Bump `lodash` from 4.17.21 to 4.17.23 ([#1235](https://github.com/zapier/zapier-platform/pull/1235))
## 4.0.1
_Release 2025-11-14_
- :bug: fix "Template string must be a string" when auth mapping contains non-string values ([#1195](https://github.com/zapier/zapier-platform/pull/1195))
## 4.0.0
_Released 2025-10-30_
We updated several dependencies of legacy-scripting-runner to address security vulnerabilities. This includes a **breaking change** :exclamation: from underscore's `_.template()` function, where the `_.template(templateString, data)` usage is no longer supported. You will have to change your legacy scripting code to use `_.template(templateString)(data)` instead.
That's the only breaking change. For details and more dependency updates, see the PR linked below:
- :exclamation: Update underscore, lodash, xmldom ([#1177](https://github.com/zapier/zapier-platform/pull/1177))
## 3.8.18
_Released 2025-07-17_
- :bug: Make `sanitizeHeaders` middleware an optional import ([#1050](https://github.com/zapier/zapier-platform/pull/1050))
## 3.8.17
- :bug: Ensure `z.request` renders `{{curlies}}` on different zapier-platform-core versions ([#1037](https://github.com/zapier/zapier-platform/pull/1037))
## 3.8.16
- :bug: sync `z.request` should log `request_params` ([#1017](https://github.com/zapier/zapier-platform/pull/1017))
## 3.8.15
- :hammer: Trim newline and whitespaces from request header ([#1000](https://github.com/zapier/zapier-platform/pull/1000))
## 3.8.14
- :bug: Revert `aws-sdk v2` bundling change from 3.8.13 ([#916](https://github.com/zapier/zapier-platform/pull/916)). This release is essentially the same as 3.8.12.
## 3.8.13
- :hammer: Add `aws-sdk v2` to dependency list ([#912](https://github.com/zapier/zapier-platform/pull/912))
## 3.8.11
- :bug: sync `z.request` doesn't produce an HTTP log ([#566](https://github.com/zapier/zapier-platform/pull/566))
## 3.8.10
- :bug: Add support for `StopRequestException` in more methods ([#561](https://github.com/zapier/zapier-platform/pull/561))
## 3.8.9
- :bug: `StopRequestException` should be caught instead of thrown ([#558](https://github.com/zapier/zapier-platform/pull/558))
## 3.8.8
- :bug: Make `logResponse` import backward compatible to fix `func.apply is not a function` error ([#548](https://github.com/zapier/zapier-platform/pull/548))
## 3.8.7
- :bug: Replace deasync with synckit to fix hanging ([#509](https://github.com/zapier/zapier-platform/pull/509))
## 3.8.6
- :bug: Fix issues with file uploading ([#496](https://github.com/zapier/zapier-platform/pull/496))
- :nail_care: Pass `legacy.skipEncodingChars` to `z.request()` ([#501](https://github.com/zapier/zapier-platform/pull/501))
## 3.8.5
- :bug: Prune nulls and undefined's from query params ([#446](https://github.com/zapier/zapier-platform/pull/446))
- :bug: Handle string array output from a scriptless create/action ([#447](https://github.com/zapier/zapier-platform/pull/447))
- :bug: Encode `request.data` in `form-urlencoded` for `pre` methods ([#448](https://github.com/zapier/zapier-platform/pull/448))
## 3.8.4
- :bug: Make sure `redirect_uri` is available during `oauth2.refresh` ([#444](https://github.com/zapier/zapier-platform/pull/444))
- :bug: Don't send auth with `z.dehydrateFile(url, {})` ([#445](https://github.com/zapier/zapier-platform/pull/445))
## 3.8.3
- :bug: Fix empty auth params when merging `request.url` and `reqeust.params` ([#441](https://github.com/zapier/zapier-platform/pull/441))
## 3.8.2
- :bug: Merge `request.url` to `request.params` before making a request ([#435](https://github.com/zapier/zapier-platform/pull/435))
- :bug: Default to auth fields if auth mapping is empty ([#438](https://github.com/zapier/zapier-platform/pull/438))
## 3.8.1
- :bug: Add support for `z.reqeust({ json: true, body: {...} })` ([#418](https://github.com/zapier/zapier-platform/pull/418))
- :bug: Empty `request.data` should send an empty request body instead of an empty object ([#423](https://github.com/zapier/zapier-platform/pull/423))
- :bug: Fix duplicate headers with session auth ([#424](https://github.com/zapier/zapier-platform/pull/424))
## 3.8.0
- :tada: Include `isBulkRead` to `bundle.meta` ([#414](https://github.com/zapier/zapier-platform/pull/414))
- :bug: Fix `Cannot read property 'errors' of undefined` on auth refresh ([#410](https://github.com/zapier/zapier-platform/pull/410))
- :bug: Fix `JSON results array could not be located` when a trigger response is "null" ([#415](https://github.com/zapier/zapier-platform/pull/415))
## 3.7.17
- :bug: Allow `pre_write` method to "cancel" multipart body by emptying `request.files` ([#394](https://github.com/zapier/zapier-platform/pull/394))
## 3.7.16
- :bug: Allow `auth` to be an object for `z.request` ([#366](https://github.com/zapier/zapier-platform/pull/366))
## 3.7.15
- :bug: Fix another edge case that can lead to duplicate API Key headers ([#365](https://github.com/zapier/zapier-platform/pull/365))
## 3.7.14
- :bug: Fix case-insesitive duplicate auth headers when using "API Key in Header" ([#364](https://github.com/zapier/zapier-platform/pull/364))
## 3.7.13
- :bug: `inputData` should take precedence over `authData` when auth fields are not saved yet ([#359](https://github.com/zapier/zapier-platform/pull/359))
## 3.7.12
- :bug: Add `bundle.trigger_data` to `pre_subscribe` and `post_subscribe` ([#342](https://github.com/zapier/zapier-platform/pull/342))
- :bug: Fix inconsistency with optional file fields ([#344](https://github.com/zapier/zapier-platform/pull/344))
## 3.7.11
- :bug: Yet-to-save auth fields should be in `bundle.auth_fields` ([#331](https://github.com/zapier/zapier-platform/pull/331))
## 3.7.10
- :bug: Fix `pre_oauthv2_token` and `pre_oauthv2_refresh` discrepancies ([#329](https://github.com/zapier/zapier-platform/pull/329))
## 3.7.9
- :bug: Throwing `ErrorException` should appear in an error log ([#309](https://github.com/zapier/zapier-platform/pull/309))
## 3.7.8
- :bug: Better choose between `ResponseError` and script error ([#305](https://github.com/zapier/zapier-platform/pull/305))
## 3.7.7
- :bug: Shouldn't double encode when `pre` method also encodes the URL ([#304](https://github.com/zapier/zapier-platform/pull/304))
## 3.7.6
- :bug: `bundle.request.data` should always be an object in `pre_oauthv2_refresh` ([#294](https://github.com/zapier/zapier-platform/pull/294))
## 3.7.5
- :bug: Make response headers case-insensitive ([#253](https://github.com/zapier/zapier-platform/pull/253))
- :bug: `bundle.request.data` should be undefined in `pre_custom_action_fields` ([#256](https://github.com/zapier/zapier-platform/pull/256))
## 3.7.4
- :bug: Fix env vars in curlies not resolving properly ([#237](https://github.com/zapier/zapier-platform/pull/237))
## 3.7.3
- :bug: Encode URL before sending a request ([#235](https://github.com/zapier/zapier-platform/pull/235))
- :hammer: Refactor tests to use Zapier-hosted httpbin ([#228](https://github.com/zapier/zapier-platform/pull/228))
- :hammer: Fix tests where inline function source isn't compiled ([#226](https://github.com/zapier/zapier-platform/pull/226))
## 3.7.2
- :bug: Prune body if `allowGetBody` and body is empty ([#224](https://github.com/zapier/zapier-platform/pull/224))
## 3.7.1
- :bug: Allow `post_read_resource` to return an array ([#219](https://github.com/zapier/zapier-platform/pull/219))
- :hammer: Upgrade dependencies ([#218](https://github.com/zapier/zapier-platform/pull/218))
## 3.7.0
- :tada: More complete bundle logs ([#213](https://github.com/zapier/zapier-platform/pull/213))
- :bug: Trim bloated bundles for `KEY_post` methods ([#213](https://github.com/zapier/zapier-platform/pull/213))
## 3.6.0
- :nail_care: Allow GET requests to have body (requires zapier-platform-core 9.4.0+) ([#195](https://github.com/zapier/zapier-platform/pull/195))
- :bug: More "reliably interpolate arrays or objects to a string" ([#203](https://github.com/zapier/zapier-platform/pull/203))
- :hammer: Upgrade `request` package ([#196](https://github.com/zapier/zapier-platform/pull/196))
## 3.5.0
- :tada: Add support for unbounded curlies ([#194](https://github.com/zapier/zapier-platform/pull/194))
## 3.4.0
- :tada: Allow to "reliably interpolate arrays or objects to a string" ([#190](https://github.com/zapier/zapier-platform/pull/190))
## 3.3.3
- :bug: Run post method first before throwing error for response status ([#183](https://github.com/zapier/zapier-platform/pull/183))
## 3.3.2
- :bug: Fix missing `console.log` ([#182](https://github.com/zapier/zapier-platform/pull/182))
## 3.3.1
- :bug: Add full jQuery support ([#181](https://github.com/zapier/zapier-platform/pull/181))
## 3.3.0
- :tada: Log converted bundles ([#177](https://github.com/zapier/zapier-platform/pull/177))

View file

@ -0,0 +1,3 @@
Copyright (c) Zapier, Inc.
This repository is part of Zapier Platform. By downloading, installing, accessing, or using any part of the Zapier Platform, including this repository, you agree to the Zapier Platform Agreement, which can be found at: https://zapier.com/platform/tos. If you do not agree to the Zapier Platform Agreement, you may not download, install, access, or use any part of the Zapier Platform, including this repository.

View file

@ -0,0 +1,51 @@
# Zapier Platform Legacy Runner
This is Zapier's Legacy Scripting Runner, used by Web Builder apps converted to [CLI](https://docs.zapier.com/platform).
This allows you to run methods from existing Scripting code in CLI, and handles the bundle conversion and method availability automatically for you.
You shouldn't have to install anything. This package will be required and installed by your app automatically, if necessary, after running `zapier-platform convert`. You only need [CLI installed](https://docs.zapier.com/platform) (i.e. `npm i -g zapier-platform-cli`).
## Steps
1. `zapier-platform convert <YOUR_APP_ID> <FOLDER_TO_PUT_APP_INTO>`
2. `cd <FOLDER_TO_PUT_APP_INTO> && npm install`
3. `zapier-platform test`.
Note `scripting.js` should look very familiar to you.
## Known Limitations
- `z.cookie_jar` is unavailable (uncommon).
- `bundle.zap` won't be filled out in most cases (CLI doesn't receive this information except for `performSubscribe` and `performUnsubscribe` in Hooks).
## Development
Note this section is intended for Zapier engineers, not for App Developers.
1. Clone this repo and run `npm link` inside of it;
2. Convert an app;
3. Inside the app run `npm link zapier-platform-legacy-scripting-runner`.
## Testing
`npm test` runs some unit tests, `npm run ci-test` pulls the "full-test" app and runs `zapier-platform test` in it.
## Releasing
1. Run `npm version [patch|minor|major]` to update the version in `package.json` and push a version tag to GitHub.
2. Wait for [Travis](https://travis-ci.org/zapier/zapier-platform-legacy-scripting-runner) to publish the package to npm.
## Test Repos
Some repos might be private. They might have more instructions in their own READMEs.
This is the "full test" for the legacy-scripting-runner:
- [Full Test](https://github.com/zapier/zapier-platform-app-converted-full-test)
A few converted sample apps (proofs of concept):
- [Google Maps](https://github.com/zapier/zapier-platform-app-converted-google-maps)
- [Instapaper](https://github.com/zapier/zapier-platform-app-converted-instapaper)
- [Remember The Milk](https://github.com/zapier/zapier-platform-app-converted-remember-the-milk)

View file

@ -0,0 +1,5 @@
'use strict';
const atob = (string) => {
return Buffer.from(string, 'base64').toString('binary');
};
module.exports = atob;

View file

@ -0,0 +1,5 @@
'use strict';
const btoa = (string) => {
return Buffer.from(string, 'binary').toString('base64');
};
module.exports = btoa;

View file

@ -0,0 +1,310 @@
const _ = require('lodash');
const { isFileField, hasFileFields, LazyFile } = require('./file');
// Max parts a key can have for unflattening
const MAX_KEY_PARTS = 6;
// Replace '{{bundle.inputData.abc}}' with '{{abc}}'
const undoCurlyReplacement = (str) =>
str ? str.replace(/{{\s*bundle\.[^.]+\.([^}\s]+)\s*}}/g, '{{$1}}') : str;
// Unflatten from {key__child: value} to {key: {child: value}}
const unflattenObject = (data, separator = '__') => {
if (Object(data) !== data || _.isArray(data)) {
return data;
}
const keys = Object.keys(data);
_.each(keys, (key) => {
if (
key.includes(separator) &&
!key.startsWith(separator) &&
!key.endsWith(separator)
) {
const value = data[key];
const keyParts = key.split(separator, MAX_KEY_PARTS);
let i;
let previousProp = data;
let currentProp = data;
for (i = 0; i < keyParts.length; i++) {
currentProp[keyParts[i]] = currentProp[keyParts[i]] || {};
previousProp = currentProp;
currentProp = currentProp[keyParts[i]];
}
previousProp[keyParts[i - 1]] = value;
delete data[key];
}
});
return data;
};
const convertToQueryString = (object) => {
if (!object) {
return '';
}
const objectKeys = Object.keys(object);
const queryStringItems = _.map(objectKeys, (key) => `${key}=${object[key]}`);
return queryStringItems.join('&');
};
//
// Methods with logic for bundleConverter
//
const addAuthData = (event, bundle, convertedBundle) => {
// Get authData only for basic auth here, to include in the request
const { username, password } = _.get(bundle, 'authData', {});
if (username && password) {
convertedBundle.request.auth = [username, password];
}
// OAuth2 specific
if (event.name.startsWith('auth.oauth2')) {
convertedBundle.oauth_data = {
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
};
convertedBundle.load = _.get(bundle, 'request.body', {});
}
};
const addInputData = (event, bundle, convertedBundle) => {
if (event.name === 'auth.connectionLabel') {
convertedBundle.test_result = bundle.inputData;
} else if (event.name.startsWith('trigger.')) {
convertedBundle.trigger_fields = bundle.inputData;
convertedBundle.trigger_fields_raw =
bundle.inputDataRaw || bundle.inputData;
} else if (event.name.startsWith('create.')) {
convertedBundle.action_fields = bundle._unflatInputData;
convertedBundle.action_fields_full = bundle.inputData;
convertedBundle.action_fields_raw = bundle.inputDataRaw || bundle.inputData;
} else if (event.name.startsWith('search.')) {
convertedBundle.search_fields = bundle.inputData;
if (event.name.startsWith('search.resource')) {
convertedBundle.read_fields = event.results || bundle.inputData;
convertedBundle.read_context = bundle.inputData;
}
} else if (event.name === 'hydrate.method') {
Object.assign(convertedBundle, bundle.inputData.bundle);
} else if (event.name.startsWith('auth.oauth2.token')) {
// Overwrite convertedBundle.auth_fields with bundle.inputData
convertedBundle.auth_fields = Object.keys(
convertedBundle.auth_fields,
).reduce((result, k) => {
result[k] = bundle.inputData[k] || convertedBundle.auth_fields[k];
return result;
}, {});
}
};
const addHookData = (event, bundle, convertedBundle) => {
if (event.name === 'trigger.hook') {
convertedBundle.request = bundle.rawRequest || convertedBundle.request;
convertedBundle.cleaned_request = bundle.cleanedRequest;
if (!convertedBundle.request.querystring) {
convertedBundle.request.querystring = convertToQueryString(
bundle.inputData,
);
}
if (!convertedBundle.request.content) {
convertedBundle.request.content = convertedBundle.request.data || '';
}
} else if (event.name.startsWith('trigger.hook.subscribe')) {
convertedBundle.target_url = bundle.targetUrl;
convertedBundle.subscription_url = bundle.targetUrl;
convertedBundle.event = bundle._legacyEvent;
convertedBundle.trigger_data = bundle.inputData;
} else if (event.name.startsWith('trigger.hook.unsubscribe')) {
convertedBundle.target_url = bundle.targetUrl;
convertedBundle.subscription_url = bundle.targetUrl;
convertedBundle.subscribe_data = bundle.subscribeData;
convertedBundle.event = bundle._legacyEvent;
convertedBundle.trigger_data = bundle.inputData;
}
};
const addRequestData = async (event, z, bundle, convertedBundle) => {
Object.assign(
convertedBundle.request.headers,
_.get(bundle, 'request.headers'),
);
Object.assign(
convertedBundle.request.params,
_.get(bundle, 'request.params'),
);
const body = _.get(bundle, 'request.body');
if (!_.isEmpty(body)) {
let data = body;
let files;
if (typeof data !== 'string' && !event.name.startsWith('auth.oauth2')) {
if (hasFileFields(bundle)) {
// Exclude file fields from request.data
data = Object.keys(body)
.filter((k) => !isFileField(k, bundle))
.reduce((result, k) => {
result[k] = body[k];
return result;
}, {});
const fileFieldKeys = Object.keys(body).filter((k) =>
isFileField(k, bundle),
);
const fileMetas = await Promise.all(
fileFieldKeys.map((k) => LazyFile(body[k]).meta()),
);
files = _.zip(fileFieldKeys, fileMetas)
.map(([k, meta]) => {
const urlOrContent = body[k];
return [k, [meta.filename, urlOrContent, meta.contentType]];
})
.reduce((result, [k, file]) => {
result[k] = file;
return result;
}, {});
}
data = JSON.stringify(data);
}
convertedBundle.request.data = data;
if (!_.isEmpty(files)) {
convertedBundle.request.files = files;
delete convertedBundle.request.headers['Content-Type'];
}
} else if (event.name.startsWith('auth.oauth2.refresh.pre')) {
// Make sure bundle.request.data is an object
convertedBundle.request.data = convertedBundle.request.data || {};
} else if (event.name.startsWith('create')) {
if (
!_.isEmpty(bundle._unflatInputData) ||
(!event.name.includes('.input') && !event.name.includes('.output'))
) {
// Only stringify an empty object when we aren't fetching custom fields
convertedBundle.request.data = JSON.stringify(bundle._unflatInputData);
}
}
};
const addResponse = (event, bundle, convertedBundle) => {
if (event.name.endsWith('.post')) {
convertedBundle.response = { ...event.response };
convertedBundle.response.status_code = convertedBundle.response.status;
if (convertedBundle.response.request) {
convertedBundle.response.request = {
...convertedBundle.response.request,
};
// `request.input` contains the entire app definition, which is big and
// unnecessary for legacy scripting
delete convertedBundle.response.request.input;
}
}
};
const convertBundleMeta = (meta) => {
if (_.isEmpty(meta)) {
return {};
}
const newMeta = {
auth_test:
meta.auth_test === undefined ? meta.isTestingAuth : meta.auth_test,
frontend:
meta.frontend === undefined ? meta.isLoadingSample : meta.frontend,
prefill:
meta.prefill === undefined ? meta.isFillingDynamicDropdown : meta.prefill,
hydrate: true,
test_poll:
meta.test_poll === undefined ? meta.isTestingAuth : meta.test_poll,
first_poll:
meta.first_poll === undefined ? meta.isPopulatingDedupe : meta.first_poll,
limit: meta.limit,
page: meta.page,
isBulkRead: meta.isBulkRead || false,
};
newMeta.standard_poll = !newMeta.test_poll;
return newMeta;
};
// Convert bundle from CLI to WB based on which event to run
const bundleConverter = async (bundle, event, z) => {
let requestMethod = _.get(bundle, 'request.method');
if (!requestMethod) {
if (
event.name.startsWith('create') ||
event.name.startsWith('auth.oauth2') ||
event.name.startsWith('trigger.hook.subscribe')
) {
requestMethod = 'POST';
} else if (event.name === 'trigger.hook.unsubscribe.pre') {
requestMethod = 'DELETE';
} else {
requestMethod = 'GET';
}
}
// From inputData, remove keys that are in fieldsExcludedFromBody
const excludedFieldKeys = bundle._fieldsExcludedFromBody || [];
const filteredInputData = Object.keys(bundle.inputData || {})
.filter((key) => !excludedFieldKeys.includes(key))
.reduce((fields, key) => {
return { [key]: bundle.inputData[key], ...fields };
}, {});
// Attach to bundle so we can reuse it
bundle._unflatInputData = unflattenObject(filteredInputData);
const meta = convertBundleMeta(bundle.meta);
const zap = _.get(bundle, 'meta.zap') || { id: 0 };
const convertedBundle = {
request: {
method: requestMethod,
url: undoCurlyReplacement(_.get(bundle, 'request.url', '')),
headers: {
'Content-Type': 'application/json',
},
params: {},
data: !requestMethod || requestMethod === 'GET' ? null : '',
},
auth_fields: _.get(bundle, 'authData', {}),
meta,
zap,
};
if (bundle._legacyUrl) {
convertedBundle.raw_url = convertedBundle.url_raw = undoCurlyReplacement(
bundle._legacyUrl,
);
}
addAuthData(event, bundle, convertedBundle);
addInputData(event, bundle, convertedBundle);
await addRequestData(event, z, bundle, convertedBundle);
addHookData(event, bundle, convertedBundle);
addResponse(event, bundle, convertedBundle);
return convertedBundle;
};
module.exports = {
bundleConverter,
unflattenObject,
};

View file

@ -0,0 +1,66 @@
'use strict';
const _ = require('lodash');
const util = require('util');
class AppError extends Error {
constructor(message, code, status) {
super(
JSON.stringify({
message,
code,
status,
}),
);
this.name = 'AppError';
this.doNotContextify = true;
}
}
// Make some of the errors we'll use!
const createError = (name) => {
const NewError = function (message) {
this.name = name;
this.message = message || '';
Error.call(this);
Error.captureStackTrace(this, this.constructor);
};
util.inherits(NewError, Error);
return NewError;
};
const names = [
'HaltedError',
'StopRequestError',
'ExpiredAuthError',
'RefreshAuthError',
'DehydrateError',
];
const cliErrors = _.reduce(
names,
(error, name) => {
error[name] = createError(name);
return error;
},
{
Error: AppError,
},
);
const exceptions = {
ErrorException: cliErrors.Error,
HaltedException: cliErrors.HaltedError,
StopRequestException: cliErrors.StopRequestError,
ExpiredAuthException: cliErrors.ExpiredAuthError,
RefreshTokenException: cliErrors.RefreshAuthError,
InvalidSessionException: cliErrors.RefreshAuthError, // In CLI, RefreshAuthError works the same for both OAuth and Session
DehydrateException: cliErrors.DehydrateError,
};
const DEFINED_ERROR_NAMES = ['AppError', ...names];
module.exports = {
...exceptions,
DEFINED_ERROR_NAMES,
};

View file

@ -0,0 +1,183 @@
// A module handling file upload, file fields, and file scripting.
const urllib = require('url');
const _ = require('lodash');
const fetch = require('node-fetch');
const markFileFieldsInBundle = (bundle, inputFields) => {
const fileFieldKeys = inputFields
.filter((field) => field.type === 'file')
.map((field) => field.key);
if (fileFieldKeys.length > 0) {
// Add it to bundle so that functions that don't have access to app
// definition, such as bundleConverter, knows which fields are files
bundle._fileFieldKeys = fileFieldKeys;
}
};
const hasFileFields = (bundle) => {
return bundle._fileFieldKeys && bundle._fileFieldKeys.length > 0;
};
const isFileField = (fieldKey, bundle) => {
if (!bundle._fileFieldKeys) {
return false;
}
return bundle._fileFieldKeys.indexOf(fieldKey) >= 0;
};
const isAnyFileFieldSet = (bundle) => {
const body = _.get(bundle, 'request.body');
if (body && bundle._fileFieldKeys) {
for (const k of bundle._fileFieldKeys) {
if (body[k]) {
return true;
}
}
}
return false;
};
const isUrl = (str) => {
try {
const parsed = new urllib.URL(str);
return (
(parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
parsed.hostname
);
} catch (e) {
return false;
}
};
const extractFilenameFromContent = (content) =>
content.substr(0, 12).replace('.txt', '') + ' ... .txt';
const extractFilenameFromContentDisposition = (value) => {
let filename = '';
// Follows RFC 6266
const patterns = [
// Example: "attachment; filename*= UTF-8''%e2%82%ac%20rates"
/filename\*\s*=\s*[a-z0-9_-]+''(.*)(?:;|$)/gi,
// Example: 'INLINE; FILENAME= "an example.html"'
/filename\s*=\s*"([^"]+)"/gi,
// Example: 'Attachment; filename=example.html'
/filename\s*=\s*([^ ]+)/gi,
];
for (const pattern of patterns) {
const match = pattern.exec(value);
if (match) {
filename = match[1];
break;
}
}
if (filename) {
filename = decodeURIComponent(filename);
}
return filename;
};
const extractFilenameFromUrl = (url) => {
const pathname = new urllib.URL(url).pathname;
if (pathname) {
const parts = pathname.split('/');
return parts[parts.length - 1] || '';
}
return '';
};
const downloadFile = async (url) => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Got ${response.statusText} when download file: ${url}`);
}
const disposition = response.headers.get('content-disposition');
const filename = disposition
? extractFilenameFromContentDisposition(disposition)
: extractFilenameFromUrl(response.url);
const contentType =
response.headers.get('content-type') || 'application/octet-stream';
const buffer = await response.buffer();
return {
meta: { filename, contentType },
content: buffer,
};
};
const ContentBackedLazyFile = (content, fileMeta) => {
const meta = async () => {
return {
filename: fileMeta.filename || extractFilenameFromContent(content),
contentType: fileMeta.contentType || 'text/plain',
};
};
// readStream is only used by FormData.append(). And FormData.append(key,
// data, options) accepts a string for its `data` argument, so instead of
// trying to make the string a readable stream, we can just return the
// string here.
const readStream = async () => content;
return { meta, readStream };
};
const UrlBackedLazyFile = (url, fileMeta) => {
const hasCompleteMeta = fileMeta.filename && fileMeta.contentType;
let cachedFile;
const downloadFileWithCache = async (fileUrl) => {
// Cache file so when we call LazyFile.meta or LazyFile.readStream, we
// don't need to send an HTTP request again
if (cachedFile) {
return cachedFile;
}
const file = await downloadFile(fileUrl);
cachedFile = file;
return file;
};
const meta = async () => {
if (hasCompleteMeta) {
return fileMeta;
}
const file = await downloadFileWithCache(url);
return _.extend(file.meta, fileMeta);
};
const readStream = async () => {
const file = await downloadFileWithCache(url);
return file.content;
};
return { meta, readStream };
};
const LazyFile = (urlOrContent, fileMeta, options) => {
fileMeta = fileMeta || {};
options = options || {};
if (options.dontLoadUrl || !isUrl(urlOrContent)) {
return ContentBackedLazyFile(urlOrContent, fileMeta);
}
return UrlBackedLazyFile(urlOrContent, fileMeta);
};
module.exports = {
markFileFieldsInBundle,
hasFileFields,
isFileField,
isAnyFileFieldSet,
LazyFile,
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,331 @@
const querystring = require('querystring');
const _ = require('lodash');
const isPrimitive = (x) => {
const tipe = typeof x;
return (
tipe === 'string' || tipe === 'number' || tipe === 'boolean' || tipe == null
);
};
const renderTemplate = (templateString, context) => {
// Security: Ensure templateString is a string and not user-controlled
if (!isPrimitive(templateString)) {
throw new Error('Template string must be a primitive');
}
const re = /{{([^}]+)}}/g;
// _.template doesn't allow us to set defaults, so we need to make sure all the
// variables in templateString are defined (as empty strings) in context
const defaults = _.clone(context);
let match = re.exec(templateString);
while (match) {
const key = match[1].trim();
defaults[key] = '';
match = re.exec(templateString);
}
const finalContext = _.defaults(_.clone(context), defaults);
const options = {
interpolate: re,
// Security: Disable code evaluation to prevent injection
evaluate: false,
escape: false,
};
// Security: Use safe template compilation
try {
return _.template(templateString, options)(finalContext);
} catch (error) {
// Log template errors but don't expose internal details
console.error('Template rendering error:', error.message);
return templateString; // Return original string on error
}
};
const getLowerHeaders = (headers) =>
Object.entries(headers).reduce((result, [k, v]) => {
result[k.toLowerCase()] = v;
return result;
}, {});
const renderAuthMapping = (authMapping, authData) => {
if (_.isEmpty(authMapping)) {
return authData;
}
return Object.entries(authMapping).reduce((result, [k, v]) => {
result[k] = renderTemplate(v, authData);
return result;
}, {});
};
const applyAuthMappingInHeaders = (authMapping, req, authData) => {
const rendered = renderAuthMapping(authMapping, authData);
const lowerHeaders = getLowerHeaders(req.headers);
Object.entries(rendered).forEach(([k, v]) => {
const lowerKey = k.toLowerCase();
if (!lowerHeaders[lowerKey]) {
req.headers[k] = v;
}
});
};
const applyAuthMappingInQuerystring = (authMapping, req, authData) => {
const rendered = renderAuthMapping(authMapping, authData);
Object.entries(rendered).forEach(([k, v]) => {
req.params[k] = req.params[k] || v;
});
};
const createBeforeRequest = (app) => {
const authType = _.get(app, 'authentication.type');
const authMapping = _.get(app, 'legacy.authentication.mapping');
const placement = _.get(app, 'legacy.authentication.placement') || 'header';
const sessionAuthInHeader = (req, z, bundle) => {
if (!_.isEmpty(bundle.authData)) {
applyAuthMappingInHeaders(authMapping, req, bundle.authData);
}
return req;
};
const sessionAuthInQuerystring = (req, z, bundle) => {
if (!_.isEmpty(bundle.authData)) {
applyAuthMappingInQuerystring(authMapping, req, bundle.authData);
}
return req;
};
const sessionAuthInBoth = (req, z, bundle) => {
if (!_.isEmpty(bundle.authData)) {
applyAuthMappingInHeaders(authMapping, req, bundle.authData);
applyAuthMappingInQuerystring(authMapping, req, bundle.authData);
}
return req;
};
const getGrantType = (req) => {
const grantType = _.get(req, 'params.grant_type');
if (grantType) {
return grantType;
}
const contentType = req.headers['Content-Type'] || '';
if (contentType.includes('application/json')) {
try {
return JSON.parse(req.body).grant_type;
} catch (err) {
return null;
}
}
return querystring.parse(req.body).grant_type;
};
const oauth2InHeader = (req, z, bundle) => {
if (bundle.authData.access_token && getGrantType(req) !== 'refresh_token') {
req.headers.Authorization =
req.headers.Authorization || `Bearer ${bundle.authData.access_token}`;
}
return req;
};
const oauth2InQuerystring = (req, z, bundle) => {
if (bundle.authData.access_token && getGrantType(req) !== 'refresh_token') {
req.params.access_token =
req.params.access_token || bundle.authData.access_token;
}
return req;
};
const oauth2InBoth = (req, z, bundle) => {
if (bundle.authData.access_token && getGrantType(req) !== 'refresh_token') {
req.headers.Authorization =
req.headers.Authorization || `Bearer ${bundle.authData.access_token}`;
req.params.access_token =
req.params.access_token || bundle.authData.access_token;
}
return req;
};
const apiKeyInHeader = (req, z, bundle) => {
if (!_.isEmpty(bundle.authData)) {
applyAuthMappingInHeaders(authMapping, req, bundle.authData);
}
return req;
};
const apiKeyInQuerystring = (req, z, bundle) => {
if (!_.isEmpty(bundle.authData)) {
applyAuthMappingInQuerystring(authMapping, req, bundle.authData);
}
return req;
};
const basicDigestAuth = (req, z, bundle) => {
if (!bundle._legacyBasicDigestAuthMiddlewareApplied) {
const username = renderTemplate(
authMapping.username || '',
bundle.authData,
);
const password = renderTemplate(
authMapping.password || '',
bundle.authData,
);
bundle.authData.username = username;
bundle.authData.password = password;
bundle._legacyBasicDigestAuthMiddlewareApplied = true;
}
return req;
};
const oauth1 = (req, z, bundle) => {
if (
bundle.authData &&
bundle.authData.oauth_token &&
bundle.authData.oauth_token_secret
) {
req.auth = req.auth || {};
let templateContext;
if (!req.auth.oauth_consumer_key) {
templateContext = Object.assign({}, bundle.authData, bundle.inputData);
req.auth.oauth_consumer_key = renderTemplate(
process.env.CLIENT_ID,
templateContext,
);
}
if (!req.auth.oauth_consumer_secret) {
if (!templateContext) {
templateContext = Object.assign(
{},
bundle.authData,
bundle.inputData,
);
}
req.auth.oauth_consumer_secret = renderTemplate(
process.env.CLIENT_SECRET,
templateContext,
);
}
req.auth.oauth_token =
req.auth.oauth_token || bundle.authData.oauth_token;
req.auth.oauth_token_secret =
req.auth.oauth_token_secret || bundle.authData.oauth_token_secret;
}
return req;
};
let authBefore;
if (authType === 'session') {
authBefore = {
header: sessionAuthInHeader,
querystring: sessionAuthInQuerystring,
both: sessionAuthInBoth,
}[placement];
} else if (authType === 'oauth2') {
authBefore = {
header: oauth2InHeader,
querystring: oauth2InQuerystring,
both: oauth2InBoth,
}[placement];
} else if (authType === 'custom') {
authBefore = {
header: apiKeyInHeader,
querystring: apiKeyInQuerystring,
}[placement];
} else if (authType === 'basic' || authType === 'digest') {
authBefore = basicDigestAuth;
} else if (authType === 'oauth1') {
authBefore = oauth1;
}
if (!authBefore) {
authBefore = (req) => req;
}
const pruneEmptyBodyForGET = (req, z, bundle) => {
if (req.allowGetBody && req.method === 'GET') {
const contentType = req.headers['Content-Type'] || '';
try {
const parsedBody = contentType.includes('application/json')
? JSON.parse(req.body)
: req.body;
if (_.isEmpty(parsedBody)) {
delete req.body;
}
} catch (err) {
// Ignore
}
}
return req;
};
return (req, z, bundle) => {
req = authBefore(req, z, bundle);
return pruneEmptyBodyForGET(req, z, bundle);
};
};
const proxyHeaders = (headers) => {
const proxy = {
get: (target, prop) => {
const original = Reflect.get(target, prop);
if (typeof original === 'function' || typeof original === 'symbol') {
// defaults to defined functions on the Headers class; the symbol type
// is used for accessing an internal map
return original;
}
try {
// try to retrieve the header via the get() function
return target.get(prop);
} catch {
// otherwise, default to original target[prop] value
return original;
}
},
};
return new Proxy(headers, proxy);
};
const createAfterResponse = (app) => {
const authType = _.get(app, 'authentication.type');
const autoRefresh = _.get(app, 'authentication.oauth2Config.autoRefresh');
const throwForStaleAuth = (response, z) => {
if (response.status === 401) {
throw new z.errors.RefreshAuthError('Authentication needs refreshing');
}
return response;
};
const makeHeaderCaseInsensitive = (response, z) => {
response.headers = proxyHeaders(response.headers);
return response;
};
let afterResponse;
if (authType === 'session' || (authType === 'oauth2' && autoRefresh)) {
afterResponse = throwForStaleAuth;
}
if (!afterResponse) {
afterResponse = (response) => response;
}
return (response, z, bundle) => {
response = afterResponse(response, z, bundle);
return makeHeaderCaseInsensitive(response);
};
};
module.exports = {
createBeforeRequest,
createAfterResponse,
renderTemplate,
};

View file

@ -0,0 +1,52 @@
{
"name": "zapier-platform-legacy-scripting-runner",
"version": "4.0.8",
"description": "Zapier's Legacy Scripting Runner, used by Web Builder apps converted to CLI.",
"repository": "zapier/zapier-platform",
"homepage": "https://platform.zapier.com/",
"author": "Zapier Engineering <contact@zapier.com>",
"license": "SEE LICENSE IN LICENSE",
"main": "index.js",
"files": [
"/*.js"
],
"scripts": {
"lint": "eslint .",
"test": "CLIENT_ID=1234 CLIENT_SECRET=asdf AWS_ACCESS_KEY_ID=fake AWS_SECRET_ACCESS_KEY=fake mocha --recursive -t 20s --exit",
"test:debug": "CLIENT_ID=1234 CLIENT_SECRET=asdf AWS_ACCESS_KEY_ID=fake AWS_SECRET_ACCESS_KEY=fake mocha inspect --recursive -t 20s --exit",
"preversion": "git pull",
"postversion": "git push && git push --tags",
"validate": "pnpm test && pnpm lint"
},
"engines": {
"node": ">=8.10.0",
"npm": ">=5.6.0"
},
"dependencies": {
"async": "2.6.4",
"flat": "5.0.2",
"form-data": "2.3.3",
"jquery": "3.5.0",
"jsdom": "7.0.0",
"lodash": "4.18.1",
"moment-timezone": "0.5.43",
"node-fetch": "2.6.7",
"request": "2.88.2",
"synckit": "0.6.0",
"underscore": "1.13.8",
"@xmldom/xmldom": "0.8.13"
},
"peerDependencies": {
"zapier-platform-core": ">=14"
},
"peerDependenciesMeta": {
"zapier-platform-core": {
"optional": true
}
},
"devDependencies": {
"aws-sdk": "^2.1397.0",
"nock": "13.3.1",
"zapier-platform-core": ">=7.6.0"
}
}

View file

@ -0,0 +1,22 @@
const { promisify } = require('util');
const request = require('request');
const { runAsWorker } = require('synckit');
const { isMainThread } = require('worker_threads');
if (!isMainThread) {
const asyncRequest = promisify(request);
runAsWorker(async (options) => {
const response = await asyncRequest(options);
// Remove unnecessary fields as the result will serialized by
// https://developer.mozilla.org/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
return {
statusCode: response.statusCode,
headers: { ...response.headers },
body: response.body,
};
});
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
const AUTH_JSON_SERVER_URL =
process.env.AUTH_JSON_SERVER_URL ||
'https://auth-json-server.zapier-staging.com';
const HTTPBIN_URL =
process.env.HTTPBIN_URL || 'https://httpbin.zapier-tooling.com';
module.exports = {
AUTH_JSON_SERVER_URL,
HTTPBIN_URL,
};

View file

@ -0,0 +1,35 @@
const { AUTH_JSON_SERVER_URL } = require('../constants');
const testAuthSource = `
const responsePromise = z.request({
url: '${AUTH_JSON_SERVER_URL}/me'
});
return responsePromise.then(response => {
if (response.status !== 200) {
throw new Error('Auth failed');
}
return z.JSON.parse(response.content);
});
`;
module.exports = {
legacy: {
authentication: {
mapping: { 'X-Api-Key': '{{api_key}}' },
placement: 'header',
},
},
authentication: {
type: 'custom',
test: { source: testAuthSource },
fields: [
{
key: 'api_key',
label: 'API Key',
type: 'string',
required: true,
},
],
},
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,60 @@
const { AUTH_JSON_SERVER_URL } = require('../constants');
const testAuthSource = `
const responsePromise = z.request({
url: '${AUTH_JSON_SERVER_URL}/me'
});
return responsePromise.then(response => {
if (response.status !== 200) {
throw new Error('Auth failed');
}
return z.JSON.parse(response.content);
});
`;
const getAuthorizeUrlSource = `
return z.legacyScripting.run(bundle, 'auth.oauth2.authorize');
`;
const getAccessTokenSource = `
return z.legacyScripting.run(bundle, 'auth.oauth2.token');
`;
const refreshAccessTokenSource = `
return z.legacyScripting.run(bundle, 'auth.oauth2.refresh');
`;
module.exports = {
legacy: {
authentication: {
placement: 'header',
mapping: {},
},
},
authentication: {
type: 'oauth2',
test: { source: testAuthSource },
fields: [
// No need to define access_token and refresh_token here, they will be
// added automatically by the backend
{
key: 'something_custom',
type: 'string',
required: true,
computed: true,
},
],
oauth2Config: {
authorizeUrl: {
source: getAuthorizeUrlSource,
},
getAccessToken: {
source: getAccessTokenSource,
},
refreshAccessToken: {
source: refreshAccessTokenSource,
},
autoRefresh: true,
},
},
};

View file

@ -0,0 +1,47 @@
'use strict';
const testAuthSource = `
return z.legacyScripting.run(bundle, 'trigger', 'contact_full');
`;
const getSessionKeySource = `
return z.legacyScripting.run(bundle, 'auth.session');
`;
const getConnectionLabelSource = `
return z.legacyScripting.run(bundle, 'auth.connectionLabel');
`;
module.exports = {
legacy: {
authentication: {
mapping: {
'X-Api-Key': '{{key1}}{{key2}}',
},
placement: 'header',
},
testTrigger: 'contact_full',
},
authentication: {
type: 'session',
test: { source: testAuthSource },
fields: [
{
key: 'username',
label: 'Username',
type: 'string',
required: true,
},
{
key: 'password',
label: 'Password',
type: 'password',
required: true,
},
],
sessionConfig: {
perform: { source: getSessionKeySource },
},
connectionLabel: { source: getConnectionLabelSource },
},
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,34 @@
require('should');
describe('Utils/Libraries', () => {
it('btoa', (done) => {
const btoa = require('../btoa');
const result = btoa('something');
result.should.equal('c29tZXRoaW5n');
done();
});
it('atob', (done) => {
const atob = require('../atob');
const result = atob('c29tZXRoaW5n');
result.should.equal('something');
done();
});
describe('$', () => {
const $ = require('../$');
it('$.param()', (done) => {
const result = $.param({ test: 'something', more: true, also: '@' });
result.should.equal('test=something&more=true&also=%40');
done();
});
it('$.parseXML()', (done) => {
const xml = $.parseXML('<do><something>also</something></do>');
const result = xml.getElementsByTagName('do').length;
result.should.equal(1);
done();
});
});
});

View file

@ -0,0 +1,88 @@
const should = require('should');
const scriptingRunner = require('../index');
describe('scriptingRunner', () => {
const defaultBundle = {
_legacyUrl: 'https://zapier.com',
inputData: {
user: 'Zapier',
},
authData: {
apiKey: 'Zapier-API-Key',
},
meta: {
frontend: false,
prefill: false,
},
};
const z = {
request: () => {},
};
it('should return nothing if there is no scripting', (done) => {
const event = {
name: 'trigger.poll',
key: 'trigger',
response: {
status: 200,
content: '[{"id": 1, "name": "Zapier"}]',
},
};
const bundle = defaultBundle;
const Zap = {};
const legacyScriptingRunner = scriptingRunner(Zap);
legacyScriptingRunner
.runEvent(event, z, bundle)
.then((result) => {
should(result).eql(undefined);
done();
})
.catch(done);
});
it('should return nothing if there is no event', (done) => {
const event = {};
const bundle = defaultBundle;
const Zap = {
trigger_poll: () => true,
};
const legacyScriptingRunner = scriptingRunner(Zap);
legacyScriptingRunner
.runEvent(event, z, bundle)
.then((result) => {
should(result).eql(undefined);
done();
})
.catch(done);
});
it('should return nothing if there is no event.name', (done) => {
const event = {
key: 'trigger',
response: {
status: 200,
content: '[{"id": 1, "name": "Zapier"}]',
},
};
const bundle = defaultBundle;
const Zap = {
trigger_poll: () => true,
};
const legacyScriptingRunner = scriptingRunner(Zap);
legacyScriptingRunner
.runEvent(event, z, bundle)
.then((result) => {
should(result).eql(undefined);
done();
})
.catch(done);
});
});

View file

@ -0,0 +1,59 @@
const { renderTemplate } = require('../middleware-factory');
describe('middleware renderTemplate security', () => {
it('should handle normal template rendering', () => {
const context = { clientId: 'test123', secret: 'mysecret' };
const template = 'Client: {{clientId}}, Secret: {{secret}}';
const result = renderTemplate(template, context);
result.should.equal('Client: test123, Secret: mysecret');
});
it('should prevent code injection in middleware templates', () => {
const context = {
clientId: 'test123',
malicious: 'process.exit(1)',
};
// This should NOT execute the malicious code
const result = renderTemplate(
'ID: {{clientId}}, Value: {{malicious}}',
context,
);
result.should.equal('ID: test123, Value: process.exit(1)');
});
it('should handle non-string template input safely in middleware', () => {
const context = { test: 'value' };
(() => {
renderTemplate({}, context);
}).should.throw('Template string must be a primitive');
(() => {
renderTemplate([], context);
}).should.throw('Template string must be a primitive');
(() => {
renderTemplate(() => {
console.log('do evil stuff');
}, context);
}).should.throw('Template string must be a primitive');
});
it('should handle template errors gracefully in middleware', () => {
const context = { name: 'John' };
// Malformed template should return original string, not crash
const result = renderTemplate('{{unclosed', context);
result.should.equal('{{unclosed');
});
it('should handle undefined variables with defaults', () => {
const context = { name: 'John' };
// renderTemplate sets undefined vars to empty string
const result = renderTemplate('{{name}} {{undefined_var}}', context);
result.should.equal('John ');
});
});

View file

@ -0,0 +1,122 @@
const should = require('should');
const z = require('../zfactory')();
const { HTTPBIN_URL } = require('./constants');
describe('z', () => {
it('z.hash', (done) => {
const result = z.hash('sha256', 'my awesome string');
result.should.equal(
'97f13a1635524dd41daca6601e5d9fe07e10e62790851e527b039851b1f8b9a1',
);
done();
});
it('z.hmac', (done) => {
const result = z.hmac('sha1', 'secret', 'signme');
result.should.equal('f67a0be1fa49a3f1dbd659726d8983b838ee6e7d');
done();
});
it('z.snipify', (done) => {
const result = z.snipify('something');
result.should.equal(':censored:9:720a531ca0:');
done();
});
it('z.request - sync', () => {
const bundleRequest = {
method: 'GET',
url: `${HTTPBIN_URL}/get`,
params: {
hello: 'world',
},
headers: {
Accept: 'application/json',
},
auth: null,
data: null,
};
const response = z.request(bundleRequest);
response.should.have.property('status_code');
response.should.have.property('headers');
response.should.have.property('content');
response.status_code.should.eql(200);
const results = JSON.parse(response.content);
results.args.should.deepEqual({ hello: ['world'] });
results.headers.Accept.should.deepEqual(['application/json']);
});
it('z.request - async', (done) => {
const bundleRequest = {
method: 'POST',
url: `${HTTPBIN_URL}/post`,
params: {
hello: 'world',
},
headers: {
Accept: 'application/json',
},
auth: null,
data: JSON.stringify({
world: 'hello',
}),
};
z.request(bundleRequest, (error, response) => {
should(error).eql(null);
response.should.have.property('status_code');
response.should.have.property('headers');
response.should.have.property('content');
response.status_code.should.eql(200);
const results = JSON.parse(response.content);
results.args.should.eql({ hello: ['world'] });
// Current version of httpbin.zapier-tooling.com encodes the input in
// base64 and returns it, so we need to decode it here.
const [header, encodedBody] = results.data.split(',');
header.should.eql('data:application/octet-stream;base64');
const decodedBody = Buffer.from(encodedBody, 'base64').toString('utf8');
decodedBody.should.eql(bundleRequest.data);
results.headers.Accept.should.deepEqual(['application/json']);
done();
});
});
it('z.JSON.parse', (done) => {
const result = z.JSON.parse('{"hello": "world"}');
result.should.have.property('hello');
result.hello.should.eql('world');
const invalidJsonString = '{invalid"hello": "world"}';
try {
z.JSON.parse(invalidJsonString);
} catch (e) {
e.name.should.eql('Error');
e.message.should.eql(
`Error parsing response. We got: "${invalidJsonString}"`,
);
done();
}
});
it('z.JSON.stringify', (done) => {
const result = z.JSON.stringify({
hello: 'world',
});
result.should.equal('{"hello":"world"}');
done();
});
it('z.AWS', (done) => {
const AWS = z.AWS();
AWS.config.getCredentials(done);
});
});

View file

@ -0,0 +1,207 @@
const crypto = require('crypto');
const _ = require('lodash');
const request = require('request');
const { createSyncFn } = require('synckit');
// So `zapier build` doesn't forget to include request-worker.js
require('./request-worker');
// Converts WB `bundle.request` format to something `request` can use
const convertBundleRequest = (bundleOrBundleRequest) => {
bundleOrBundleRequest = _.extend({}, bundleOrBundleRequest);
// LEGACY: allow for the whole bundle to mistakingly be sent over
const bundleRequest = bundleOrBundleRequest.request
? bundleOrBundleRequest.request
: bundleOrBundleRequest;
if (
bundleRequest.auth &&
Array.isArray(bundleRequest.auth) &&
bundleRequest.auth.length === 2
) {
bundleRequest.auth = {
user: bundleRequest.auth[0],
password: bundleRequest.auth[1],
};
}
if (!bundleRequest.qs && bundleRequest.params) {
bundleRequest.qs = bundleRequest.params;
}
if (!bundleRequest.body && bundleRequest.data) {
bundleRequest.body = bundleRequest.data;
}
delete bundleRequest.params;
delete bundleRequest.data;
return bundleRequest;
};
const parseBody = (body) => {
if (body) {
if (typeof body === 'string' || body.writeInt32BE) {
return String(body);
}
return body;
}
return null;
};
// Converts `request`'s response into a simplified object
const convertResponse = (response) => {
if (response) {
return {
status_code: response.statusCode,
headers: _.extend({}, response.headers),
content: parseBody(response.body),
};
}
return {};
};
const syncRequest = createSyncFn(require.resolve('./request-worker'));
const zfactory = (zcli, app, logger) => {
const AWS = () => {
// Direct require breaks the build as the module isn't found by browserify
const moduleName = 'aws-sdk';
return require(moduleName);
};
const jsonParse = (str) => {
try {
return JSON.parse(str);
} catch (err) {
let preview = str;
if (str && str.length > 100) {
preview = str.substr(0, 100);
}
throw new Error(`Error parsing response. We got: "${preview}"`);
}
};
const jsonStringify = (obj) => {
try {
return JSON.stringify(obj);
} catch (err) {
throw new Error(err.message);
}
};
const sendHttpLog = (req, res) => {
// Log fields here intend to match the ones in createHttpPatch in core
const method = (req.method || 'GET').toUpperCase();
const url = req.url || req.uri;
const responseBody =
typeof res.content === 'string'
? res.content
: 'Could not show response content';
logger(`${res.status_code} ${method} ${url}`, {
log_type: 'http',
request_type: 'devplatform-outbound',
request_url: url,
request_method: method,
request_headers: req.headers,
request_params: req.qs,
request_data: req.data,
request_via_client: false,
response_status_code: res.status_code,
response_headers: res.headers,
response_content: responseBody,
});
};
const requestMethod = (bundleRequest, callback) => {
const options = convertBundleRequest(bundleRequest);
if (_.isFunction(callback)) {
return request(options, (err, response) =>
callback(err, convertResponse(response)),
);
}
const normalizedOptions = request.initParams(options);
const response = syncRequest(normalizedOptions);
const convertedResponse = convertResponse(response);
// syncRequest() is done by a worker thread, which isn't httpPatch'ed, so we
// need to explicit write the http log here
if (logger) {
sendHttpLog(normalizedOptions, convertedResponse);
}
return convertedResponse;
};
const hash = (
algorithm,
string,
encoding = 'hex',
inputEncoding = 'binary',
) => {
const hasher = crypto.createHash(algorithm);
hasher.update(string, inputEncoding);
return hasher.digest(encoding);
};
const hmac = (algorithm, key, string, encoding = 'hex') => {
const hasher = crypto.createHmac(algorithm, key);
hasher.update(string);
return hasher.digest(encoding);
};
const snipify = (string) => {
const SALT = process.env.SECRET_SALT || 'doesntmatterreally';
if (!_.isString(string)) {
return null;
}
const length = string.length;
string += SALT;
const result = hash('sha256', string);
return `:censored:${length}:${result.substr(0, 10)}:`;
};
const dehydrate = (method, bundle) => {
return zcli.dehydrate(app.hydrators.legacyMethodHydrator, {
method,
bundle,
});
};
const dehydrateFile = (url, requestOptions, meta) => {
return zcli.dehydrateFile(app.hydrators.legacyFileHydrator, {
url,
request: requestOptions,
meta,
});
};
return {
AWS,
JSON: {
parse: jsonParse,
stringify: jsonStringify,
},
request: requestMethod,
hash,
hmac,
snipify,
dehydrate,
dehydrateFile,
};
};
module.exports = zfactory;