From 68cb1a59a2a27328cd32181ccfd7f6f898d0a873 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Sat, 18 May 2019 03:46:01 +0200 Subject: [PATCH] feat(gatsby-source-contentful): add options validation and more detailed error messages (#9231) --- .../gatsby-source-contentful/package.json | 2 + .../src/__tests__/fetch.js | 227 ++++++++++++++++++ .../src/__tests__/plugin-options.js | 218 +++++++++++++++++ .../gatsby-source-contentful/src/fetch.js | 58 +++-- .../src/gatsby-node.js | 19 +- .../src/plugin-options.js | 119 +++++++++ packages/gatsby/package.json | 2 +- yarn.lock | 30 ++- 8 files changed, 643 insertions(+), 32 deletions(-) create mode 100644 packages/gatsby-source-contentful/src/__tests__/fetch.js create mode 100644 packages/gatsby-source-contentful/src/__tests__/plugin-options.js create mode 100644 packages/gatsby-source-contentful/src/plugin-options.js diff --git a/packages/gatsby-source-contentful/package.json b/packages/gatsby-source-contentful/package.json index ad81d05d2647d..5a3ee2973a432 100644 --- a/packages/gatsby-source-contentful/package.json +++ b/packages/gatsby-source-contentful/package.json @@ -11,12 +11,14 @@ "axios": "^0.18.0", "base64-img": "^1.0.3", "bluebird": "^3.5.0", + "chalk": "^2.3.2", "contentful": "^6.1.0", "deep-map": "^1.5.0", "fs-extra": "^4.0.2", "gatsby-plugin-sharp": "^2.0.37", "gatsby-source-filesystem": "^2.0.36", "is-online": "^7.0.0", + "joi": "^14.0.0", "json-stringify-safe": "^5.0.1", "lodash": "^4.17.10", "qs": "^6.4.0" diff --git a/packages/gatsby-source-contentful/src/__tests__/fetch.js b/packages/gatsby-source-contentful/src/__tests__/fetch.js new file mode 100644 index 0000000000000..e565ad2a33d42 --- /dev/null +++ b/packages/gatsby-source-contentful/src/__tests__/fetch.js @@ -0,0 +1,227 @@ +// disable output coloring for tests +process.env.FORCE_COLOR = 0 + +const mockClient = { + getLocales: jest.fn(() => + Promise.resolve({ + items: [ + { + code: `en-us`, + default: true, + }, + ], + }) + ), + sync: jest.fn(() => { + return { + entries: [], + assets: [], + deletedEntries: [], + deletedAssets: [], + } + }), + getContentTypes: jest.fn(async () => { + return { + items: [], + total: 0, + } + }), +} + +jest.mock(`contentful`, () => { + return { + createClient: jest.fn(() => mockClient), + } +}) + +jest.mock(`../plugin-options`, () => { + return { + ...jest.requireActual(`../plugin-options`), + formatPluginOptionsForCLI: jest.fn(() => `formatPluginOptionsForCLIMock`), + } +}) + +// jest so test output is not filled with contentful plugin logs +global.console = { log: jest.fn(), time: jest.fn(), timeEnd: jest.fn() } + +const contentful = require(`contentful`) +const fetchData = require(`../fetch`) +const { + formatPluginOptionsForCLI, + createPluginConfig, +} = require(`../plugin-options`) + +const options = { + spaceId: `rocybtov1ozk`, + accessToken: `6f35edf0db39085e9b9c19bd92943e4519c77e72c852d961968665f1324bfc94`, + host: `host`, + environment: `env`, +} + +const pluginConfig = createPluginConfig(options) + +let realProcess +beforeAll(() => { + realProcess = global.process + + global.process = { + ...realProcess, + exit: jest.fn(), + } +}) + +const reporter = { + panic: jest.fn(), +} + +beforeEach(() => { + global.process.exit.mockClear() + reporter.panic.mockClear() + mockClient.getLocales.mockClear() + formatPluginOptionsForCLI.mockClear() + contentful.createClient.mockClear() +}) + +afterAll(() => { + global.process = realProcess +}) + +it(`calls contentful.createClient with expected params`, async () => { + await fetchData({ pluginConfig, reporter }) + expect(reporter.panic).not.toBeCalled() + expect(contentful.createClient).toBeCalledWith( + expect.objectContaining({ + accessToken: `6f35edf0db39085e9b9c19bd92943e4519c77e72c852d961968665f1324bfc94`, + environment: `env`, + host: `host`, + space: `rocybtov1ozk`, + }) + ) +}) + +it(`calls contentful.createClient with expected params and default fallbacks`, async () => { + await fetchData({ + pluginConfig: createPluginConfig({ + accessToken: `6f35edf0db39085e9b9c19bd92943e4519c77e72c852d961968665f1324bfc94`, + spaceId: `rocybtov1ozk`, + }), + reporter, + }) + expect(reporter.panic).not.toBeCalled() + expect(contentful.createClient).toBeCalledWith( + expect.objectContaining({ + accessToken: `6f35edf0db39085e9b9c19bd92943e4519c77e72c852d961968665f1324bfc94`, + environment: `master`, + host: `cdn.contentful.com`, + space: `rocybtov1ozk`, + }) + ) +}) + +describe(`Displays troubleshooting tips and detailed plugin options on contentful client error`, () => { + it(`Generic fallback error`, async () => { + mockClient.getLocales.mockImplementation(() => { + throw new Error(`error`) + }) + + await fetchData({ pluginConfig, reporter }) + + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`Accessing your Contentful space failed`) + ) + + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`formatPluginOptionsForCLIMock`) + ) + + expect(formatPluginOptionsForCLI).toBeCalledWith( + expect.objectContaining({ + ...options, + }), + undefined + ) + }) + + it(`Connection error`, async () => { + mockClient.getLocales.mockImplementation(() => { + const err = new Error(`error`) + err.code = `ENOTFOUND` + throw err + }) + + await fetchData({ pluginConfig, reporter }) + + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`You seem to be offline`) + ) + + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`formatPluginOptionsForCLIMock`) + ) + + expect(formatPluginOptionsForCLI).toBeCalledWith( + expect.objectContaining({ + ...options, + }), + undefined + ) + }) + + it(`API 404 response handling`, async () => { + mockClient.getLocales.mockImplementation(() => { + const err = new Error(`error`) + err.response = { status: 404 } + throw err + }) + + await fetchData({ pluginConfig, reporter }) + + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`Check if host and spaceId settings are correct`) + ) + + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`formatPluginOptionsForCLIMock`) + ) + + expect(formatPluginOptionsForCLI).toBeCalledWith( + expect.objectContaining({ + ...options, + }), + { + host: `Check if setting is correct`, + spaceId: `Check if setting is correct`, + } + ) + }) + + it(`API authorization error handling`, async () => { + mockClient.getLocales.mockImplementation(() => { + const err = new Error(`error`) + err.response = { status: 401 } + throw err + }) + + await fetchData({ pluginConfig, reporter }) + + expect(reporter.panic).toBeCalledWith( + expect.stringContaining( + `Check if accessToken and environment are correct` + ) + ) + + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`formatPluginOptionsForCLIMock`) + ) + + expect(formatPluginOptionsForCLI).toBeCalledWith( + expect.objectContaining({ + ...options, + }), + { + accessToken: `Check if setting is correct`, + environment: `Check if setting is correct`, + } + ) + }) +}) diff --git a/packages/gatsby-source-contentful/src/__tests__/plugin-options.js b/packages/gatsby-source-contentful/src/__tests__/plugin-options.js new file mode 100644 index 0000000000000..c8ef42db942b8 --- /dev/null +++ b/packages/gatsby-source-contentful/src/__tests__/plugin-options.js @@ -0,0 +1,218 @@ +// disable output coloring for tests +process.env.FORCE_COLOR = 0 + +const { + maskText, + validateOptions, + formatPluginOptionsForCLI, +} = require(`../plugin-options`) + +const maskedCharacterCount = input => + input.split(``).filter(char => char === `*`).length + +describe(`Mask text`, () => { + it.each([ + [`handles very short inputs`, `a`, `*`], + [`handles short inputs`, `abcd`, `***d`], + [ + `handles long inputs`, + `abcdefghijklmnopqrstuvwxyz`, + `**********************wxyz`, + ], + ])(`%s`, (_, input, expectedResult) => { + const result = maskText(input) + + // explicit check for result + expect(result).toEqual(expectedResult) + + // has same string length + expect(result.length).toEqual(input.length) + // hides 75% or more of input + expect(maskedCharacterCount(result)).toBeGreaterThanOrEqual( + input.length * 0.75 + ) + // show at max 4 characters + expect(result.length - maskedCharacterCount(result)).toBeLessThanOrEqual(4) + }) +}) + +describe(`Formatting plugin options for CLI`, () => { + it(`kitchen sink`, () => { + const options = { + accessToken: `abcdefghijklmnopqrstuvwxyz`, + spaceId: `abcdefgh`, + host: `wat`, + localeFilter: locale => locale.code === `de`, + } + const annotations = { + spaceId: `foo`, + environment: `bar`, + } + const result = formatPluginOptionsForCLI(options, annotations) + + const lines = result.split(`\n`) + + // doesn't leak secrets when listing plugin options + expect(result).toContain(`accessToken`) + expect(result).not.toContain(options.accessToken) + expect(result).toContain(`spaceId`) + expect(result).not.toContain(options.spaceId) + + // mark if default value is used + expect(lines.find(line => line.includes(`downloadLocal`))).toContain( + `default value` + ) + expect(lines.find(line => line.includes(`environment`))).toContain( + `default value` + ) + + // annotations are added + expect(lines.find(line => line.includes(`spaceId`))).toContain( + annotations.spaceId + ) + expect(lines.find(line => line.includes(`environment`))).toContain( + annotations.environment + ) + }) +}) + +describe(`Options validation`, () => { + const reporter = { + panic: jest.fn(), + } + + beforeEach(() => { + reporter.panic.mockClear() + }) + + it(`Passes with valid options`, () => { + validateOptions( + { + reporter, + }, + { + spaceId: `spaceId`, + accessToken: `accessToken`, + localeFilter: locale => locale.code === `de`, + downloadLocal: false, + } + ) + + expect(reporter.panic).not.toBeCalled() + }) + + it(`Fails with missing required options`, () => { + validateOptions( + { + reporter, + }, + {} + ) + + expect(reporter.panic).toBeCalledWith( + expect.stringContaining( + `Problems with gatsby-source-contentful plugin options` + ) + ) + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`"accessToken" is required`) + ) + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`"accessToken" is required`) + ) + }) + + it(`Fails with empty options`, () => { + validateOptions( + { + reporter, + }, + { + environment: ``, + host: ``, + accessToken: ``, + spaceId: ``, + } + ) + + expect(reporter.panic).toBeCalledWith( + expect.stringContaining( + `Problems with gatsby-source-contentful plugin options` + ) + ) + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`"environment" is not allowed to be empty`) + ) + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`"host" is not allowed to be empty`) + ) + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`"accessToken" is not allowed to be empty`) + ) + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`"spaceId" is not allowed to be empty`) + ) + }) + + it(`Fails with options of wrong types`, () => { + validateOptions( + { + reporter, + }, + { + environment: 1, + host: [], + accessToken: true, + spaceId: {}, + localeFilter: `yup`, + downloadLocal: 5, + } + ) + + expect(reporter.panic).toBeCalledWith( + expect.stringContaining( + `Problems with gatsby-source-contentful plugin options` + ) + ) + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`"environment" must be a string`) + ) + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`"host" must be a string`) + ) + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`"accessToken" must be a string`) + ) + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`"spaceId" must be a string`) + ) + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`"localeFilter" must be a Function`) + ) + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`"downloadLocal" must be a boolean`) + ) + }) + + it(`Fails with undefined option keys`, () => { + validateOptions( + { + reporter, + }, + { + spaceId: `spaceId`, + accessToken: `accessToken`, + wat: true, + } + ) + + expect(reporter.panic).toBeCalledWith( + expect.stringContaining( + `Problems with gatsby-source-contentful plugin options` + ) + ) + expect(reporter.panic).toBeCalledWith( + expect.stringContaining(`"wat" is not allowed`) + ) + }) +}) diff --git a/packages/gatsby-source-contentful/src/fetch.js b/packages/gatsby-source-contentful/src/fetch.js index 608791bab5a17..0c4e09f224189 100644 --- a/packages/gatsby-source-contentful/src/fetch.js +++ b/packages/gatsby-source-contentful/src/fetch.js @@ -1,17 +1,23 @@ const contentful = require(`contentful`) const _ = require(`lodash`) +const chalk = require(`chalk`) const normalize = require(`./normalize`) +const { formatPluginOptionsForCLI } = require(`./plugin-options`) -module.exports = async ({ spaceId, syncToken, ...options }) => { +module.exports = async ({ syncToken, reporter, pluginConfig }) => { // Fetch articles. console.time(`Fetch Contentful data`) console.log(`Starting to fetch data from Contentful`) - const client = contentful.createClient({ - space: spaceId, - ...options, - }) + const contentfulClientOptions = { + space: pluginConfig.get(`spaceId`), + accessToken: pluginConfig.get(`accessToken`), + host: pluginConfig.get(`host`), + environment: pluginConfig.get(`environment`), + } + + const client = contentful.createClient(contentfulClientOptions) // The sync API puts the locale in all fields in this format { fieldName: // {'locale': value} } so we need to get the space and its default local. @@ -23,17 +29,40 @@ module.exports = async ({ spaceId, syncToken, ...options }) => { console.log(`Fetching default locale`) locales = await client.getLocales().then(response => response.items) defaultLocale = _.find(locales, { default: true }).code - locales = locales.filter(options.localeFilter || (() => true)) + locales = locales.filter(pluginConfig.get(`localeFilter`)) console.log(`default locale is : ${defaultLocale}`) } catch (e) { - console.log( - `Accessing your Contentful space failed. Perhaps you're offline or the spaceId/accessToken is incorrect.` - ) - console.log( - `Try running setting GATSBY_CONTENTFUL_OFFLINE=true to see if we can serve from cache.` - ) + let details + let errors + if (e.code === `ENOTFOUND`) { + details = `You seem to be offline` + } else if (e.response) { + if (e.response.status === 404) { + // host and space used to generate url + details = `Endpoint not found. Check if ${chalk.yellow( + `host` + )} and ${chalk.yellow(`spaceId`)} settings are correct` + errors = { + host: `Check if setting is correct`, + spaceId: `Check if setting is correct`, + } + } else if (e.response.status === 401) { + // authorization error + details = `Authorization error. Check if ${chalk.yellow( + `accessToken` + )} and ${chalk.yellow(`environment`)} are correct` + errors = { + accessToken: `Check if setting is correct`, + environment: `Check if setting is correct`, + } + } + } - process.exit(1) + reporter.panic(`Accessing your Contentful space failed. +Try setting GATSBY_CONTENTFUL_OFFLINE=true to see if we can serve from cache. +${details ? `\n${details}\n` : ``} +Used options: +${formatPluginOptionsForCLI(pluginConfig.getOriginalPluginOptions(), errors)}`) } let currentSyncData @@ -41,8 +70,7 @@ module.exports = async ({ spaceId, syncToken, ...options }) => { let query = syncToken ? { nextSyncToken: syncToken } : { initial: true } currentSyncData = await client.sync(query) } catch (e) { - console.log(`error fetching contentful data`, e) - process.exit(1) + reporter.panic(`Fetching contentful data failed`, e) } // We need to fetch content types with the non-sync API as the sync API diff --git a/packages/gatsby-source-contentful/src/gatsby-node.js b/packages/gatsby-source-contentful/src/gatsby-node.js index 38e590cb2b746..20f51b5c9bd5c 100644 --- a/packages/gatsby-source-contentful/src/gatsby-node.js +++ b/packages/gatsby-source-contentful/src/gatsby-node.js @@ -5,6 +5,7 @@ const fs = require(`fs-extra`) const normalize = require(`./normalize`) const fetchData = require(`./fetch`) +const { createPluginConfig, validateOptions } = require(`./plugin-options`) const { downloadContentfulAssets } = require(`./download-contentful-assets`) const conflictFieldPrefix = `contentful` @@ -21,6 +22,7 @@ const restrictedNodeFields = [ exports.setFieldsOnGraphQLNodeType = require(`./extend-node-type`).extendNodeType +exports.onPreBootstrap = validateOptions /*** * Localization algorithm * @@ -33,8 +35,8 @@ exports.setFieldsOnGraphQLNodeType = require(`./extend-node-type`).extendNodeTyp */ exports.sourceNodes = async ( - { actions, getNode, getNodes, createNodeId, hasNodeChanged, store, cache }, - options + { actions, getNode, getNodes, createNodeId, store, cache, reporter }, + pluginOptions ) => { const { createNode, deleteNode, touchNode, setPluginStatus } = actions @@ -59,11 +61,13 @@ exports.sourceNodes = async ( return } + const pluginConfig = createPluginConfig(pluginOptions) + const createSyncToken = () => - `${options.spaceId}-${options.environment}-${options.host}` + `${pluginConfig.get(`spaceId`)}-${pluginConfig.get( + `environment` + )}-${pluginConfig.get(`host`)}` - options.host = options.host || `cdn.contentful.com` - options.environment = options.environment || `master` // default is always master // Get sync token if it exists. let syncToken if ( @@ -85,7 +89,8 @@ exports.sourceNodes = async ( locales, } = await fetchData({ syncToken, - ...options, + reporter, + pluginConfig, }) const entryList = normalize.buildEntryList({ @@ -210,7 +215,7 @@ exports.sourceNodes = async ( }) }) - if (options.downloadLocal) { + if (pluginConfig.get(`downloadLocal`)) { await downloadContentfulAssets({ actions, createNodeId, diff --git a/packages/gatsby-source-contentful/src/plugin-options.js b/packages/gatsby-source-contentful/src/plugin-options.js new file mode 100644 index 0000000000000..9587a27a37f92 --- /dev/null +++ b/packages/gatsby-source-contentful/src/plugin-options.js @@ -0,0 +1,119 @@ +const Joi = require(`joi`) +const chalk = require(`chalk`) + +const _ = require(`lodash`) + +const defaultOptions = { + host: `cdn.contentful.com`, + environment: `master`, + downloadLocal: false, + localeFilter: () => true, +} + +const createPluginConfig = pluginOptions => { + const conf = { ...defaultOptions, ...pluginOptions } + + return { + get: key => conf[key], + getOriginalPluginOptions: () => pluginOptions, + } +} + +const optionsSchema = Joi.object().keys({ + accessToken: Joi.string() + .required() + .empty(), + spaceId: Joi.string() + .required() + .empty(), + host: Joi.string().empty(), + environment: Joi.string().empty(), + downloadLocal: Joi.boolean(), + localeFilter: Joi.func(), + // default plugins passed by gatsby + plugins: Joi.array(), +}) + +const maskedFields = [`accessToken`, `spaceId`] + +const validateOptions = ({ reporter }, options) => { + const result = optionsSchema.validate(options, { abortEarly: false }) + if (result.error) { + const errors = {} + result.error.details.forEach(detail => { + errors[detail.path[0]] = detail.message + }) + reporter.panic(`Problems with gatsby-source-contentful plugin options: +${exports.formatPluginOptionsForCLI(options, errors)}`) + } +} + +const formatPluginOptionsForCLI = (pluginOptions, errors = {}) => { + const optionKeys = new Set( + Object.keys(pluginOptions) + .concat(Object.keys(defaultOptions)) + .concat(Object.keys(errors)) + ) + + const getDisplayValue = key => { + const formatValue = value => { + if (_.isFunction(value)) { + return `[Function]` + } else if (maskedFields.includes(key) && typeof value === `string`) { + return JSON.stringify(maskText(value)) + } + return JSON.stringify(value) + } + + if (typeof pluginOptions[key] !== `undefined`) { + return chalk.green(formatValue(pluginOptions[key])) + } else if (typeof defaultOptions[key] !== `undefined`) { + return chalk.dim(formatValue(defaultOptions[key])) + } + + return chalk.dim(`undefined`) + } + + const lines = [] + optionKeys.forEach(key => { + if (key === `plugins`) { + // skip plugins field automatically added by gatsby + return + } + + lines.push( + `${key}${ + typeof pluginOptions[key] === `undefined` && + typeof defaultOptions[key] !== `undefined` + ? chalk.dim(` (default value)`) + : `` + }: ${getDisplayValue(key)}${ + typeof errors[key] !== `undefined` ? ` - ${chalk.red(errors[key])}` : `` + }` + ) + }) + return lines.join(`\n`) +} + +/** + * Mask majority of input to not leak any secrets + * @param {string} input + * @returns {string} masked text + */ +const maskText = input => { + // show just 25% of string up to 4 characters + const hiddenCharactersLength = + input.length - Math.min(4, Math.floor(input.length * 0.25)) + + return `${`*`.repeat(hiddenCharactersLength)}${input.substring( + hiddenCharactersLength + )}` +} + +export { + defaultOptions, + validateOptions, + formatPluginOptionsForCLI, + maskText, + createPluginConfig, +} diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 7bc8b961a078f..132345d172ec6 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -82,7 +82,7 @@ "is-relative-url": "^2.0.0", "is-wsl": "^1.1.0", "jest-worker": "^23.2.0", - "joi": "12.x.x", + "joi": "^14.0.0", "json-loader": "^0.5.7", "json-stringify-safe": "^5.0.1", "kebab-hash": "^0.1.2", diff --git a/yarn.lock b/yarn.lock index c0dd877260ce2..d5d0bb2218295 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10861,6 +10861,11 @@ hoek@4.x.x: resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.1.tgz#9634502aa12c445dd5a7c5734b572bb8738aacbb" integrity sha512-QLg82fGkfnJ/4iy1xZ81/9SIJiq1NGFUMGs6ParyjBZr6jW2Ufj/snDqTHixNlHdPNwN2RLVD0Pi3igeK9+JfA== +hoek@5.x.x: + version "5.0.4" + resolved "https://registry.yarnpkg.com/hoek/-/hoek-5.0.4.tgz#0f7fa270a1cafeb364a4b2ddfaa33f864e4157da" + integrity sha512-Alr4ZQgoMlnere5FZJsIyfIjORBqZll5POhDsF4q64dPuJR6rNxXdDxtHSQq8OXRurhmx+PWYEE8bXRROY8h0w== + hoist-non-react-statics@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz#b09178f0122184fb95acf525daaecb4d8f45958b" @@ -12848,15 +12853,6 @@ jimp@^0.2.24: tinycolor2 "^1.1.2" url-regex "^3.0.0" -joi@12.x.x: - version "12.0.0" - resolved "https://registry.yarnpkg.com/joi/-/joi-12.0.0.tgz#46f55e68f4d9628f01bbb695902c8b307ad8d33a" - integrity sha512-z0FNlV4NGgjQN1fdtHYXf5kmgludM65fG/JlXzU6+rwkt9U5UWuXVYnXa2FpK0u6+qBuCmrm5byPNuiiddAHvQ== - dependencies: - hoek "4.x.x" - isemail "3.x.x" - topo "2.x.x" - joi@^11.1.1: version "11.4.0" resolved "https://registry.yarnpkg.com/joi/-/joi-11.4.0.tgz#f674897537b625e9ac3d0b7e1604c828ad913ccb" @@ -12866,6 +12862,15 @@ joi@^11.1.1: isemail "3.x.x" topo "2.x.x" +joi@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/joi/-/joi-14.0.0.tgz#05a206b259e702f426eb2b2e523d642fb383e6ad" + integrity sha512-jEu+bPFcsgdPr85hVyjb5D5grxLEZniT6AB1vjewrRDbuYxe2r5quyxs3E32dF8fCXcaJnlRSy4jehSpDuNMNg== + dependencies: + hoek "5.x.x" + isemail "3.x.x" + topo "3.x.x" + jpeg-js@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.2.0.tgz#53e448ec9d263e683266467e9442d2c5a2ef5482" @@ -20575,6 +20580,13 @@ topo@2.x.x: dependencies: hoek "4.x.x" +topo@3.x.x: + version "3.0.0" + resolved "https://registry.yarnpkg.com/topo/-/topo-3.0.0.tgz#37e48c330efeac784538e0acd3e62ca5e231fe7a" + integrity sha512-Tlu1fGlR90iCdIPURqPiufqAlCZYzLjHYVVbcFWDMcX7+tK8hdZWAfsMrD/pBul9jqHHwFjNdf1WaxA9vTRRhw== + dependencies: + hoek "5.x.x" + toposort@^1.0.0: version "1.0.7" resolved "https://registry.yarnpkg.com/toposort/-/toposort-1.0.7.tgz#2e68442d9f64ec720b8cc89e6443ac6caa950029"