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,85 @@
'use strict';
const jsonschema = require('jsonschema');
const AUTH_FIELD_ID = '/AuthFieldSchema';
const AUTH_FIELDS_ID = '/AuthFieldsSchema';
const FORBIDDEN_KEYS = [
'access_token',
'access-token',
'accesstoken',
'api_key',
'apikey',
'api-key',
'auth',
'jwt',
'passwd',
'password',
'pswd',
'refresh_token',
'refresh-token',
'refreshtoken',
'secret',
'set-cookie',
'set_cookie',
'setcookie',
'signature',
'token',
];
const isSensitiveKey = (key = '') =>
FORBIDDEN_KEYS.some((forbidden) => key.toLowerCase().includes(forbidden));
const checkAuthField = (field) => {
const errors = [];
// if the field key contains any forbidden substring (case-insensitive),
// AND 'isNoSecret' is true, throw a validation error
if (
(field.key === 'password' || isSensitiveKey(field.key)) &&
field.isNoSecret === true
) {
errors.push(
new jsonschema.ValidationError(
`cannot set isNoSecret as true for the sensitive key "${field.key}".`,
field,
'/AuthFieldSchema',
'instance.field',
'sensitive',
'field',
),
);
}
return errors;
};
module.exports = (definition, mainSchema) => {
const errors = [];
// Done to validate anti-examples declaratively defined in the schema
if ([AUTH_FIELD_ID, AUTH_FIELDS_ID].includes(mainSchema.id)) {
const definitions = Array.isArray(definition) ? definition : [definition];
definitions.forEach((field, index) => {
checkAuthField(field).forEach((err) => {
err.property = `instance[${index}]`;
err.stack = err.stack.replace('instance.field', err.property);
errors.push(err);
});
});
}
// If there's no authentication or no fields, we have nothing to check
if (!definition.authentication || !definition.authentication.fields) {
return errors;
}
definition.authentication.fields.forEach((field, index) => {
checkAuthField(field).forEach((err) => {
err.property = `instance.authentication.fields[${index}]`;
err.stack = err.stack.replace('instance.field', err.property);
errors.push(err);
});
});
return errors;
};

View file

@ -0,0 +1,102 @@
'use strict';
const _ = require('lodash');
const jsonschema = require('jsonschema');
const bufferedCreateConstraints = (definition) => {
const errors = [];
const actionType = 'creates';
if (definition[actionType]) {
_.each(definition[actionType], (actionDef) => {
if (actionDef.operation && actionDef.operation.buffer) {
if (!actionDef.operation.performBuffer) {
errors.push(
new jsonschema.ValidationError(
'must contain property "performBuffer" because property "buffer" is present.',
actionDef.operation,
'/BasicCreateOperationSchema',
`instance.${actionType}.${actionDef.key}.operation`,
'missing',
'performBuffer',
),
);
}
if (actionDef.operation.perform) {
errors.push(
new jsonschema.ValidationError(
'must not contain property "perform" because it is mutually exclusive with property "buffer".',
actionDef.operation,
'/BasicCreateOperationSchema',
`instance.${actionType}.${actionDef.key}.operation`,
'invalid',
'perform',
),
);
}
if (actionDef.operation.buffer.groupedBy) {
const requiredInputFields = [];
const inputFields = _.get(
actionDef,
['operation', 'inputFields'],
[],
);
inputFields.forEach((inputField) => {
if (inputField.required) {
requiredInputFields.push(inputField.key);
}
});
actionDef.operation.buffer.groupedBy.forEach((field, index) => {
if (!requiredInputFields.includes(field)) {
errors.push(
new jsonschema.ValidationError(
`cannot use optional or non-existent inputField "${field}".`,
actionDef.operation.buffer,
'/BufferConfigSchema',
`instance.${actionType}.${actionDef.key}.operation.buffer.groupedBy[${index}]`,
'invalid',
'groupedBy',
),
);
}
});
}
}
if (actionDef.operation && actionDef.operation.performBuffer) {
if (!actionDef.operation.buffer) {
errors.push(
new jsonschema.ValidationError(
'must contain property "buffer" because property "performBuffer" is present.',
actionDef.operation,
'/BasicCreateOperationSchema',
`instance.${actionType}.${actionDef.key}.operation`,
'missing',
'buffer',
),
);
}
if (actionDef.operation.perform) {
errors.push(
new jsonschema.ValidationError(
'must not contain property "perform" because it is mutually exclusive with property "performBuffer".',
actionDef.operation,
'/BasicCreateOperationSchema',
`instance.${actionType}.${actionDef.key}.operation`,
'invalid',
'perform',
),
);
}
}
});
}
return errors;
};
module.exports = bufferedCreateConstraints;

View file

@ -0,0 +1,68 @@
'use strict';
const _ = require('lodash');
const jsonschema = require('jsonschema');
const collectErrors = (inputFields, path) => {
const errors = [];
_.each(inputFields, (inputField, index) => {
if (inputField.children) {
if (inputField.children.length === 0) {
errors.push(
new jsonschema.ValidationError(
'must not be empty.',
inputField,
'/PlainFieldSchema',
`instance.${path}.inputFields[${index}].children`,
'empty',
'inputFields',
),
);
} else {
const hasDeeplyNestedChildren = _.some(
inputField.children,
(child) => child.children,
);
if (hasDeeplyNestedChildren) {
errors.push(
new jsonschema.ValidationError(
'must not contain deeply nested child fields. One level max.',
inputField,
'/PlainFieldSchema',
`instance.${path}.inputFields[${index}]`,
'deepNesting',
'inputFields',
),
);
}
}
}
});
return errors;
};
const validateFieldNesting = (definition) => {
let errors = [];
_.each(['triggers', 'searches', 'creates'], (typeOf) => {
if (definition[typeOf]) {
_.each(definition[typeOf], (actionDef) => {
if (actionDef.operation && actionDef.operation.inputFields) {
errors = errors.concat(
collectErrors(
actionDef.operation.inputFields,
`${typeOf}.${actionDef.key}`,
),
);
}
});
}
});
return errors;
};
module.exports = validateFieldNesting;

View file

@ -0,0 +1,41 @@
'use strict';
/* Each check below is expected to return a list of ValidationSchema errors. An error is defined by:
* new jsonschema.ValidationError(
* message, // string that explains the problem, like 'must not have a URL that points to AWS'
* instance, // the snippet of the app definition that is invalid
* schema, // name of the schema that failed, like '/TriggerSchema'
* propertyPath, // stringified path to problematic snippet, like 'instance.triggers.find_contact'
* name, // optional, the validation type that failed. Can make something up like 'invalidUrl'
* argument // optional
* );
*/
const checks = [
require('./searchOrCreateKeys'),
require('./deepNestedFields'),
require('./mutuallyExclusiveFields'),
require('./requiredSamples'),
require('./matchingKeys'),
require('./labelWhenVisible'),
require('./uniqueInputFieldKeys'),
require('./bufferedCreateConstraints'),
require('./requirePerformConditionally'),
require('./pollingThrottle'),
require('./AuthFieldisSafe'),
require('./inputFieldGroupsConstraints'),
require('./validateJsonFieldSchema'),
];
const runFunctionalConstraints = (definition, mainSchema) => {
return checks.reduce((errors, checkFunc) => {
const errorsForCheck = checkFunc(definition, mainSchema);
if (errorsForCheck) {
errors = errors.concat(errorsForCheck);
}
return errors;
}, []);
};
module.exports = {
run: runFunctionalConstraints,
};

View file

@ -0,0 +1,144 @@
'use strict';
const _ = require('lodash');
const jsonschema = require('jsonschema');
const actionTypes = ['triggers', 'searches', 'creates', 'bulkReads'];
const resourceMethods = ['get', 'list', 'hook', 'search', 'create'];
const validateInputFieldGroups = (
inputFields,
inputFieldGroups,
basePath,
schemaName,
) => {
const errors = [];
// Check for duplicate group keys in inputFieldGroups
const groupKeys = inputFieldGroups.map((group) => group.key);
const duplicateKeys = groupKeys.filter(
(key, index) => groupKeys.indexOf(key) !== index,
);
if (duplicateKeys.length > 0) {
duplicateKeys.forEach((duplicateKey) => {
const duplicateIndex = groupKeys.lastIndexOf(duplicateKey);
errors.push(
new jsonschema.ValidationError(
`Duplicate group key "${duplicateKey}" found in inputFieldGroups. Group keys must be unique.`,
inputFieldGroups[duplicateIndex],
schemaName,
`${basePath}.inputFieldGroups[${duplicateIndex}].key`,
'duplicateGroupKey',
'inputFieldGroups',
),
);
});
}
// Create a set of valid group keys
const validGroupKeys = new Set(groupKeys);
inputFields.forEach((inputField, index) => {
// Check children fields first - groups are not allowed in children
(inputField.children || []).forEach((childField, childIndex) => {
if (childField.group) {
errors.push(
new jsonschema.ValidationError(
`Group fields are not allowed in children fields. Remove the group property from this field.`,
childField.group,
'/PlainInputFieldSchema',
`${basePath}.inputFields[${index}].children[${childIndex}].group`,
'groupInChildren',
'group',
),
);
}
});
// Check if group reference is valid
if (inputField.group) {
if (!validGroupKeys.has(inputField.group)) {
const availableGroups =
Array.from(validGroupKeys).length > 0
? `[${Array.from(validGroupKeys).join(', ')}]`
: '[]';
errors.push(
new jsonschema.ValidationError(
`Group "${inputField.group}" is not defined in inputFieldGroups. Available groups: ${availableGroups}`,
inputField.group,
'/PlainInputFieldSchema',
`${basePath}.inputFields[${index}].group`,
'invalidGroupReference',
'group',
),
);
}
}
});
return errors;
};
const inputFieldGroupsConstraints = (definition) => {
const errors = [];
// Validate action types (triggers, searches, creates, bulkReads)
for (const actionType of actionTypes) {
const group = definition[actionType] || {};
_.each(group, (action, actionKey) => {
const inputFields = _.get(action, ['operation', 'inputFields'], []);
const inputFieldGroups = _.get(
action,
['operation', 'inputFieldGroups'],
[],
);
const basePath = `instance.${actionType}.${actionKey}.operation`;
const schemaName = '/BasicOperationSchema';
const actionErrors = validateInputFieldGroups(
inputFields,
inputFieldGroups,
basePath,
schemaName,
);
errors.push(...actionErrors);
});
}
// Validate resources
if (definition.resources) {
_.each(definition.resources, (resource, resourceKey) => {
resourceMethods.forEach((method) => {
if (resource[method] && resource[method].operation) {
const inputFields = _.get(
resource[method],
['operation', 'inputFields'],
[],
);
const inputFieldGroups = _.get(
resource[method],
['operation', 'inputFieldGroups'],
[],
);
const basePath = `instance.resources.${resourceKey}.${method}.operation`;
const schemaName = '/BasicOperationSchema';
const resourceErrors = validateInputFieldGroups(
inputFields,
inputFieldGroups,
basePath,
schemaName,
);
errors.push(...resourceErrors);
}
});
});
}
return errors;
};
module.exports = inputFieldGroupsConstraints;

View file

@ -0,0 +1,31 @@
const _ = require('lodash');
const jsonschema = require('jsonschema');
const actionTypes = ['triggers', 'searches', 'creates', 'bulkReads'];
const labelWhenVisible = (definition) => {
const errors = [];
for (const actionType of actionTypes) {
const group = definition[actionType] || {};
_.each(group, (action, key) => {
const { display } = action;
if (!display.hidden && !(display.label && display.description)) {
errors.push(
new jsonschema.ValidationError(
`visible actions must have a label and description`,
action,
`/BasicDisplaySchema`,
`instance.${actionType}.${key}.display`,
'invalid',
'key',
),
);
}
});
}
return errors;
};
module.exports = labelWhenVisible;

View file

@ -0,0 +1,35 @@
'use strict';
const _ = require('lodash');
const jsonschema = require('jsonschema');
const actionTypes = ['triggers', 'searches', 'creates'];
const matchingKeys = (definition) => {
const errors = [];
// verifies that x.key === x
// otherwise, we double results in the compiled app via core's compileApp
for (const actionType of actionTypes) {
const group = definition[actionType] || {};
_.each(group, (action, key) => {
if (action.key !== key) {
errors.push(
new jsonschema.ValidationError(
`must have a matching top-level key (found "${key}" and "${action.key}")`,
action,
`/${_.capitalize(actionType)}Schema`,
`instance.${key}.key`,
'invalid',
'key',
),
);
}
});
}
return errors;
};
module.exports = matchingKeys;

View file

@ -0,0 +1,70 @@
'use strict';
const _ = require('lodash');
const jsonschema = require('jsonschema');
// NOTE: While it would be possible to accomplish this with a solution like
// https://stackoverflow.com/questions/28162509/mutually-exclusive-property-groups#28172831
// it was harder to read and understand.
const {
INCOMPATIBLE_FIELD_SCHEMA_KEYS,
FIELD_SCHEMA_BOOLEANS,
} = require('../constants');
const verifyIncompatibilities = (inputFields, path) => {
const errors = [];
_.each(inputFields, (inputField, index) => {
_.each(INCOMPATIBLE_FIELD_SCHEMA_KEYS, ([firstField, secondField]) => {
if (_.has(inputField, firstField) && _.has(inputField, secondField)) {
// this could be ok if it's a boolean field and is falsy
// i'm reasonably sure that the editor handles this fine, but if it also checks
// for the existence of the property (not the truthiness), then there could be an issue
if (
(FIELD_SCHEMA_BOOLEANS.has(firstField) && !inputField[firstField]) ||
(FIELD_SCHEMA_BOOLEANS.has(secondField) && !inputField[secondField])
) {
return;
}
errors.push(
new jsonschema.ValidationError(
`must not contain ${firstField} and ${secondField}, as they're mutually exclusive.`,
inputField,
'/PlainFieldSchema',
`instance.${path}.inputFields[${index}]`,
'invalid',
'inputFields',
),
);
}
});
});
return errors;
};
const mutuallyExclusiveFields = (definition) => {
let errors = [];
_.each(['triggers', 'searches', 'creates'], (typeOf) => {
if (definition[typeOf]) {
_.each(definition[typeOf], (actionDef) => {
if (actionDef.operation && actionDef.operation.inputFields) {
errors = [
...errors,
...verifyIncompatibilities(
actionDef.operation.inputFields,
`${typeOf}.${actionDef.key}`,
),
];
}
});
}
});
return errors;
};
module.exports = mutuallyExclusiveFields;

View file

@ -0,0 +1,35 @@
'use strict';
const _ = require('lodash');
const jsonschema = require('jsonschema');
const pollingThrottle = (definition) => {
const errors = [];
const actionType = 'triggers';
if (definition[actionType]) {
_.each(definition[actionType], (actionDef) => {
if (
actionDef.operation &&
actionDef.operation.throttle &&
_.has(actionDef.operation.throttle, 'retry') &&
(!actionDef.operation.type || actionDef.operation.type === 'polling')
) {
errors.push(
new jsonschema.ValidationError(
'must not use the "retry" field for a polling trigger.',
actionDef.operation.throttle,
'/ThrottleObjectSchema',
`instance.${actionType}.${actionDef.key}.operation.throttle`,
'invalid',
'throttle',
),
);
}
});
}
return errors;
};
module.exports = pollingThrottle;

View file

@ -0,0 +1,35 @@
'use strict';
const _ = require('lodash');
const jsonschema = require('jsonschema');
const requirePerformConditionally = (definition) => {
const errors = [];
const actionType = 'creates';
if (definition[actionType]) {
_.each(definition[actionType], (actionDef) => {
if (
actionDef.operation &&
!actionDef.operation.buffer &&
!actionDef.operation.performBuffer &&
!actionDef.operation.perform
) {
errors.push(
new jsonschema.ValidationError(
'requires property "perform".',
actionDef.operation,
'/BasicCreateOperationSchema',
`instance.${actionType}.${actionDef.key}.operation`,
'required',
'perform',
),
);
}
});
}
return errors;
};
module.exports = requirePerformConditionally;

View file

@ -0,0 +1,56 @@
'use strict';
const _ = require('lodash');
const jsonschema = require('jsonschema');
// todo: deal with circular dep.
const RESOURCE_ID = '/ResourceSchema';
const RESOURCE_METHODS = ['get', 'hook', 'list', 'search', 'create'];
const check = (definition) => {
if (!definition.operation || _.get(definition, 'display.hidden')) {
return null;
}
const samples = _.get(definition, 'operation.sample', {});
return !_.isEmpty(samples)
? null
: new jsonschema.ValidationError(
'requires "sample", because it\'s not hidden',
definition,
definition.id,
);
};
module.exports = (definition, mainSchema) => {
let definitions = [];
if (mainSchema.id === RESOURCE_ID) {
definitions = RESOURCE_METHODS.map((method) => definition[method]).filter(
Boolean,
);
// allow method definitions to inherit the sample
if (definition.sample) {
definitions.forEach((methodDefinition) => {
if (methodDefinition.operation && !methodDefinition.operation.sample) {
methodDefinition.operation.sample = definition.sample;
}
});
}
if (!definitions.length) {
return [
new jsonschema.ValidationError(
'expected at least one resource operation',
definition,
definition.id,
),
];
}
} else {
definitions = [definition];
}
return definitions.map(check).filter(Boolean);
};

View file

@ -0,0 +1,263 @@
'use strict';
const _ = require('lodash');
const jsonschema = require('jsonschema');
const getFieldKeys = (definition, path) => {
const fields = _.get(definition, path, []);
// Filter out any `undefined` values using .filter(), which may happen due to incoming inputFields
// containing functions instead of plain Objects.
return fields.map((field) => field.key).filter((key) => key);
};
// This method differs from 'getFieldKeys' since here we obtain the actual object keys with Object.keys()
const getSearchOutputSampleKeys = (definition, searchKey) => {
const searchOutputSampleFields = _.get(
definition.searches,
`${searchKey}.operation.sample`,
{},
);
return Object.keys(searchOutputSampleFields);
};
const validateSearchCreateKeys = (definition, searchCreatesKey) => {
const searchCreates = definition[searchCreatesKey];
if (!searchCreates) {
return [];
}
const errors = [];
const searchKeys = Object.keys(definition.searches);
const createKeys = Object.keys(definition.creates);
_.each(searchCreates, (searchOrCreateDef, key) => {
const searchOrCreateKey = searchOrCreateDef.key;
const searchKey = searchOrCreateDef.search;
const createKey = searchOrCreateDef.create;
const updateKey = searchOrCreateDef.update;
const updateInputKeys = getFieldKeys(
definition.creates,
`${updateKey}.operation.inputFields`,
);
const searchInputKeys = getFieldKeys(
definition.searches,
`${searchKey}.operation.inputFields`,
);
const searchOutputKeys = getFieldKeys(
definition.searches,
`${searchKey}.operation.outputFields`,
);
const searchOutputSampleKeys = getSearchOutputSampleKeys(
definition,
searchKey,
);
// There are constraints where we check for keys in either outputFields or sample, so combining them is a shortcut
const allSearchOutputKeys = new Set([
...searchOutputKeys,
...searchOutputSampleKeys,
]);
// For some constraints, there is a difference between not "having" a key defined versus having one but with empty values
const hasSearchOutputFields = _.has(
definition.searches,
`${searchKey}.operation.outputFields`,
);
const hasSearchOutputSample = _.has(
definition.searches,
`${searchKey}.operation.sample`,
);
// Confirm searchOrCreate.key matches a searches.key (current Zapier editor limitation)
if (!definition.searches[searchOrCreateKey]) {
errors.push(
new jsonschema.ValidationError(
`must match a "key" from a search (options: ${searchKeys})`,
searchOrCreateDef,
'/SearchOrCreateSchema',
`instance.${searchCreatesKey}.${key}.key`,
'invalidKey',
'key',
),
);
}
// Confirm searchOrCreate.search matches a searches.key
if (!definition.searches[searchKey]) {
errors.push(
new jsonschema.ValidationError(
`must match a "key" from a search (options: ${searchKeys})`,
searchOrCreateDef,
'/SearchOrCreateSchema',
`instance.${searchCreatesKey}.${key}.search`,
'invalidKey',
'search',
),
);
}
// Confirm searchOrCreate.create matches a creates.key
if (!definition.creates[createKey]) {
errors.push(
new jsonschema.ValidationError(
`must match a "key" from a create (options: ${createKeys})`,
searchOrCreateDef,
'/SearchOrCreateSchema',
`instance.${searchCreatesKey}.${key}.create`,
'invalidKey',
'create',
),
);
}
// Confirm searchOrCreate.update matches a creates.key, if it is defined
if (updateKey && !definition.creates[updateKey]) {
errors.push(
new jsonschema.ValidationError(
`must match a "key" from a create (options: ${createKeys})`,
searchOrCreateDef,
'/SearchOrCreateSchema',
`instance.${searchCreatesKey}.${key}.update`,
'invalidKey',
),
);
}
// Confirm searchOrCreate.updateInputFromSearchOutput existing implies searchOrCreate.update is defined
if (searchOrCreateDef.updateInputFromSearchOutput && !updateKey) {
errors.push(
new jsonschema.ValidationError(
`requires searchOrCreates.${key}.update to be defined`,
searchOrCreateDef,
'/SearchOrCreateSchema',
`instance.${searchCreatesKey}.${key}.updateInputFromSearchOutput`,
'invalid',
),
);
}
// Confirm searchOrCreate.searchUniqueInputToOutputConstraint existing implies searchOrCreate.update is defined
if (searchOrCreateDef.searchUniqueInputToOutputConstraint && !updateKey) {
errors.push(
new jsonschema.ValidationError(
`requires searchOrCreates.${key}.update to be defined`,
searchOrCreateDef,
'/SearchOrCreateSchema',
`instance.${searchCreatesKey}.${key}.searchUniqueInputToOutputConstraint`,
'invalid',
),
);
}
// Confirm searchOrCreate.updateInputFromSearchOutput contains objects with:
// keys existing in creates[update].operation.inputFields.key
// values existing in searches[search].operation.(outputFields.key|sample keys), if they are defined
if (
updateKey &&
_.isPlainObject(searchOrCreateDef.updateInputFromSearchOutput)
) {
const updateInputOptionHint = _.isEmpty(updateInputKeys)
? '(no "key" found in inputFields)'
: `(options: ${updateInputKeys})`;
const searchOutputOptionHint = `(options: ${[...allSearchOutputKeys]})`;
for (const [updateInputField, searchOutputField] of Object.entries(
searchOrCreateDef.updateInputFromSearchOutput,
)) {
if (!updateInputKeys.includes(updateInputField)) {
errors.push(
new jsonschema.ValidationError(
`object key must match a "key" from a creates.${updateKey}.operation.inputFields ${updateInputOptionHint}`,
searchOrCreateDef,
'/SearchOrCreateSchema',
`instance.${searchCreatesKey}.${key}.updateInputFromSearchOutput`,
'invalidKey',
),
);
}
if (
(hasSearchOutputFields || hasSearchOutputSample) &&
!allSearchOutputKeys.has(searchOutputField)
) {
errors.push(
new jsonschema.ValidationError(
`object value must match a "key" from searches.${searchKey}.operation.(outputFields|sample) ${searchOutputOptionHint}`,
searchOrCreateDef,
'/SearchOrCreateSchema',
`instance.${searchCreatesKey}.${key}.updateInputFromSearchOutput`,
'invalidKey',
),
);
}
}
}
// Confirm searchOrCreate.searchUniqueInputToOutputConstraint contains objects with:
// keys existing in searches[search].operation.inputFields.key
// values existing in searches[search].operation.(outputFields.key|sample keys), if they are defined
if (
updateKey &&
_.isPlainObject(searchOrCreateDef.searchUniqueInputToOutputConstraint)
) {
const searchInputOptionHint = _.isEmpty(searchInputKeys)
? '(no "key" found in inputFields)'
: `(options: ${searchInputKeys})`;
const searchOutputOptionHint = `(options: ${[...allSearchOutputKeys]})`;
for (const [searchInputField, searchOutputField] of Object.entries(
searchOrCreateDef.searchUniqueInputToOutputConstraint,
)) {
if (!searchInputKeys.includes(searchInputField)) {
errors.push(
new jsonschema.ValidationError(
`object key must match a "key" from a searches.${searchKey}.operation.inputFields ${searchInputOptionHint}`,
searchOrCreateDef,
'/SearchOrCreateSchema',
`instance.${searchCreatesKey}.${key}.searchUniqueInputToOutputConstraint`,
'invalidKey',
),
);
}
if (
(hasSearchOutputFields || hasSearchOutputSample) &&
typeof searchOutputField === 'string' &&
!allSearchOutputKeys.has(searchOutputField)
) {
errors.push(
new jsonschema.ValidationError(
`object value must match a "key" from searches.${searchKey}.operation.(outputFields|sample) ${searchOutputOptionHint}`,
searchOrCreateDef,
'/SearchOrCreateSchema',
`instance.${searchCreatesKey}.${key}.searchUniqueInputToOutputConstraint`,
'invalidKey',
),
);
}
}
}
return errors;
});
return errors;
};
const validateSearchOrCreateKeys = (definition) => {
// searchAndCreates is an alias for searchOrCreates. They are the same, but
// you can define both searchAndCreates and searchOrCreates to avoid search
// key collision.
return [
...validateSearchCreateKeys(definition, 'searchOrCreates'),
...validateSearchCreateKeys(definition, 'searchAndCreates'),
];
};
module.exports = validateSearchOrCreateKeys;

View file

@ -0,0 +1,66 @@
'use strict';
const _ = require('lodash');
const jsonschema = require('jsonschema');
const actionTypes = ['triggers', 'searches', 'creates'];
const uniqueInputFieldKeys = (definition) => {
const errors = [];
for (const actionType of actionTypes) {
const group = definition[actionType] || {};
_.each(group, (action, key) => {
const inputFields = _.get(action, ['operation', 'inputFields'], []);
const existingKeys = {}; // map of key to where it already lives
inputFields.forEach((inputField, index) => {
// could be a string or a non-field object (`source` or `require` function obj)
if (!inputField.key) {
return;
}
if (existingKeys[inputField.key]) {
errors.push(
new jsonschema.ValidationError(
`inputField keys must be unique for each action. The key "${
inputField.key
}" is already in use at ${actionType}.${key}.operation.${
existingKeys[inputField.key]
}.key`,
inputField.key,
`/BasicOperationSchema`,
`instance.${actionType}.${key}.operation.inputFields[${index}].key`,
),
);
} else {
existingKeys[inputField.key] = `inputFields[${index}]`;
}
(inputField.children || []).forEach((subField, subFieldIndex) => {
if (existingKeys[subField.key]) {
errors.push(
new jsonschema.ValidationError(
`inputField keys must be unique for each action, even if they're children. The key "${
subField.key
}" is already in use at ${actionType}.${key}.operation.${
existingKeys[subField.key]
}.key`,
subField.key,
`/BasicOperationSchema`,
`instance.${actionType}.${key}.operation.inputFields[${index}].children[${subFieldIndex}].key`,
),
);
} else {
existingKeys[subField.key] =
`inputFields[${index}].children[${subFieldIndex}]`;
}
});
});
});
}
return errors;
};
module.exports = uniqueInputFieldKeys;

View file

@ -0,0 +1,254 @@
'use strict';
const _ = require('lodash');
const jsonschema = require('jsonschema');
const JSON_SCHEMA_SCHEMA_ID = '/JsonSchemaSchema';
const PLAIN_INPUT_FIELD_SCHEMA_ID = '/PlainInputFieldSchema';
const actionTypes = ['triggers', 'searches', 'creates', 'bulkReads'];
const resourceMethods = ['get', 'list', 'hook', 'search', 'create'];
// Load supported JSON Schema meta-schemas
const draft4MetaSchema = require('json-metaschema/draft-04-schema.json');
const draft6MetaSchema = require('json-metaschema/draft-06-schema.json');
const draft7MetaSchema = require('json-metaschema/draft-07-schema.json');
// Map of supported $schema URIs (without trailing #) to their meta-schemas
// HTTPS supported but normalized below
const SUPPORTED_META_SCHEMAS = {
'http://json-schema.org/draft-04/schema': draft4MetaSchema,
'http://json-schema.org/draft-06/schema': draft6MetaSchema,
'http://json-schema.org/draft-07/schema': draft7MetaSchema,
};
const metaValidator = new jsonschema.Validator();
// Translates jsonschema ValidationError from meta-schema validation
// into a human-readable error message.
const formatMetaSchemaError = (error, rootPath) => {
const ALLOWED_TYPE_NAMES = [
'object',
'array',
'string',
'number',
'integer',
'boolean',
'null',
];
const relativePath = error.property.replace(/^instance\.?/, '');
const fullPath = relativePath ? `${rootPath}.${relativePath}` : rootPath;
// "type" field: invalid JSON Schema type value
if (/\.type$/.test(error.property) || error.property === 'instance.type') {
if (error.name === 'anyOf') {
const value = error.instance;
if (typeof value === 'string') {
return `${fullPath}: invalid type "${value}". Must be one of: ${ALLOWED_TYPE_NAMES.join(', ')}`;
}
if (Array.isArray(value)) {
const invalid = value.filter(
(t) => typeof t !== 'string' || !ALLOWED_TYPE_NAMES.includes(t),
);
if (invalid.length > 0) {
return invalid
.map(
(t) =>
`${fullPath}: invalid type "${t}". Must be one of: ${ALLOWED_TYPE_NAMES.join(', ')}`,
)
.join('; ');
}
return `${fullPath}: must be a string or array of strings`;
}
return `${fullPath}: must be a string or array of strings`;
}
}
// Non-object where a JSON Schema object is expected
// Draft 7 allows boolean schemas, so argument may be ['object', 'boolean']
if (
error.name === 'type' &&
Array.isArray(error.argument) &&
error.argument.includes('object')
) {
return `${fullPath}: must be a valid JSON Schema object`;
}
// Non-array where an array is expected (required, enum, allOf, etc.)
if (error.name === 'type' && _.isEqual(error.argument, ['array'])) {
const fieldName = fullPath.split('.').pop();
return `${fieldName}: must be an array`;
}
// anyOf failure for fields expecting a schema or specific structure
if (error.name === 'anyOf') {
const value = error.instance;
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return `${fullPath}: must be a valid JSON Schema object`;
}
return `${fullPath}: must be a valid JSON Schema`;
}
// Default fallback
return `${fullPath}: ${error.message}`;
};
// Resolves which meta-schema to validate against based on the $schema field.
// Returns { metaSchema, error } where error is a string if $schema is unsupported.
const resolveMetaSchema = (schema) => {
if (!schema.$schema) {
return { metaSchema: draft7MetaSchema, error: null };
}
if (typeof schema.$schema !== 'string') {
return { metaSchema: null, error: '`$schema` must be a string' };
}
const normalizedUri = schema.$schema
.replace(/#$/, '')
.replace(/^https:/, 'http:');
const metaSchema = SUPPORTED_META_SCHEMAS[normalizedUri];
if (!metaSchema) {
const supported = Object.keys(SUPPORTED_META_SCHEMAS).join(', ');
return {
metaSchema: null,
error: `unsupported JSON Schema version "${schema.$schema}". Supported versions: ${supported}`,
};
}
return { metaSchema, error: null };
};
// Validates that an object is a structurally valid JSON schema
// using the appropriate meta-schema via jsonschema library.
// Returns an array of error message strings
const collectSchemaErrors = (schema, rootPath) => {
if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) {
return [`${rootPath}: must be a valid JSON Schema object`];
}
const { metaSchema, error } = resolveMetaSchema(schema);
if (error) {
return [`${rootPath}: ${error}`];
}
const result = metaValidator.validate(schema, metaSchema);
return result.errors.map((err) => formatMetaSchemaError(err, rootPath));
};
const checkSchemaField = (field, path) => {
let errors = [];
if (_.has(field, 'schema')) {
if (field.type !== 'json') {
errors.push(
new jsonschema.ValidationError(
'must have `type` set to `json` when `schema` is provided.',
field,
'/PlainInputFieldSchema',
path,
'invalidSchema',
'schema',
),
);
} else {
// Root schema type must be object or array, not primitives
const rootType = field.schema.type;
if (rootType !== undefined) {
const types = Array.isArray(rootType) ? rootType : [rootType];
const invalidTypes = types.filter(
(t) => t !== 'object' && t !== 'array',
);
if (invalidTypes.length > 0) {
errors.push(
new jsonschema.ValidationError(
`has an invalid JSON Schema in \`schema\`: schema: root \`type\` must be "object" or "array", got ${invalidTypes.map((t) => `"${t}"`).join(', ')}`,
field,
'/PlainInputFieldSchema',
path,
'invalidJsonSchema',
'schema',
),
);
// Skip meta-schema validation if root type is invalid
return errors;
}
}
const schemaErrors = collectSchemaErrors(field.schema, 'schema');
schemaErrors.forEach((message) => {
errors.push(
new jsonschema.ValidationError(
`has an invalid JSON Schema in \`schema\`: ${message}`,
field,
'/PlainInputFieldSchema',
path,
'invalidJsonSchema',
'schema',
),
);
});
}
}
// Recurse into children (nested PlainInputFieldSchema)
(field.children || []).forEach((child, childIndex) => {
errors = errors.concat(
checkSchemaField(child, `${path}.children[${childIndex}]`),
);
});
return errors;
};
const validateJsonFieldSchema = (definition, mainSchema) => {
let errors = [];
// Handle individual field validation (for auto-tests of examples/antiExamples)
if (mainSchema.id === PLAIN_INPUT_FIELD_SCHEMA_ID) {
return checkSchemaField(definition, 'instance');
} else if (mainSchema.id === JSON_SCHEMA_SCHEMA_ID) {
return checkSchemaField(
// Make a fake PlainInputField object so checkSchemaField detects a json type and `schema` property
{ key: 'placeholder_field_key', type: 'json', schema: definition },
'instance',
);
}
// Handle full app definition validation
_.each(actionTypes, (actionType) => {
if (definition[actionType]) {
_.each(definition[actionType], (actionDef) => {
if (actionDef.operation && actionDef.operation.inputFields) {
_.each(actionDef.operation.inputFields, (field, index) => {
const path = `instance.${actionType}.${actionDef.key}.operation.inputFields[${index}]`;
errors = errors.concat(checkSchemaField(field, path));
});
}
});
}
});
// Handle resources if they exist
if (definition.resources) {
_.each(definition.resources, (resource, resourceKey) => {
resourceMethods.forEach((method) => {
if (
resource[method] &&
resource[method].operation &&
resource[method].operation.inputFields
) {
_.each(resource[method].operation.inputFields, (field, index) => {
const path = `instance.resources.${resourceKey}.${method}.operation.inputFields[${index}]`;
errors = errors.concat(checkSchemaField(field, path));
});
}
});
});
}
return errors;
};
module.exports = validateJsonFieldSchema;