diff --git a/core/frontend/helpers/cancel_link.js b/core/frontend/helpers/cancel_link.js new file mode 100644 index 0000000000..35ec97d8d6 --- /dev/null +++ b/core/frontend/helpers/cancel_link.js @@ -0,0 +1,32 @@ +// # {{cancel_link}} Helper +// Usage: `{{cancel_link}}`, `{{cancel_link class="custom-cancel-class"}}`, `{{cancel_link cancelLabel="Cancel please!"}}` +// +// Should be used inside of a subscription context, e.g.: `{{#foreach @member.subscriptions}} {{cancel_link}} {{/foreach}}` +// Outputs cancel/renew links to manage subscription renewal after the subscription period ends. +// +// Defaults to class="cancel-subscription-link" errorClass="cancel-subscription-error" cancelLabel="Cancel subscription" continueLabel="Continue subscription" + +const proxy = require('./proxy'); + +const templates = proxy.templates; +const errors = proxy.errors; +const i18n = proxy.i18n; + +module.exports = function excerpt(options) { + let truncateOptions = (options || {}).hash || {}; + + if (this.id === undefined || this.cancel_at_period_end === undefined) { + throw new errors.IncorrectUsageError({message: i18n.t('warnings.helpers.cancel_link.invalidData')}); + } + + const data = { + id: this.id, + cancel_at_period_end: this.cancel_at_period_end, + class: truncateOptions.class || 'gh-subscription-cancel', + errorClass: truncateOptions.errorClass || 'gh-error gh-error-subscription-cancel', + cancelLabel: truncateOptions.cancelLabel || 'Cancel subscription', + continueLabel: truncateOptions.continueLabel || 'Continue subscription' + }; + + return templates.execute('cancel_link', data); +}; diff --git a/core/frontend/helpers/index.js b/core/frontend/helpers/index.js index f0e5b261c8..342ec1302d 100644 --- a/core/frontend/helpers/index.js +++ b/core/frontend/helpers/index.js @@ -1,13 +1,17 @@ -var coreHelpers = {}, - register = require('./register'), - registerThemeHelper = register.registerThemeHelper, - registerAsyncThemeHelper = register.registerAsyncThemeHelper, - registerAllCoreHelpers; +const proxy = require('./proxy'); +const register = require('./register'); + +const coreHelpers = {}; +const registerThemeHelper = register.registerThemeHelper; +const registerAsyncThemeHelper = register.registerAsyncThemeHelper; + +let registerAllCoreHelpers; coreHelpers.asset = require('./asset'); coreHelpers.author = require('./author'); coreHelpers.authors = require('./authors'); coreHelpers.body_class = require('./body_class'); +coreHelpers.cancel_link = require('./cancel_link'); coreHelpers.concat = require('./concat'); coreHelpers.content = require('./content'); coreHelpers.date = require('./date'); @@ -40,12 +44,26 @@ coreHelpers.title = require('./title'); coreHelpers.twitter_url = require('./twitter_url'); coreHelpers.url = require('./url'); +function labsEnabledMembers() { + let self = this, args = arguments; + + return proxy.labs.enabledHelper({ + flagKey: 'members', + flagName: 'Members', + helperName: 'cancel_link', + helpUrl: 'https://ghost.org/faq/members/' + }, () => { + return coreHelpers.cancel_link.apply(self, args); + }); +} + registerAllCoreHelpers = function registerAllCoreHelpers() { // Register theme helpers registerThemeHelper('asset', coreHelpers.asset); registerThemeHelper('author', coreHelpers.author); registerThemeHelper('authors', coreHelpers.authors); registerThemeHelper('body_class', coreHelpers.body_class); + registerThemeHelper('cancel_link', labsEnabledMembers); registerThemeHelper('concat', coreHelpers.concat); registerThemeHelper('content', coreHelpers.content); registerThemeHelper('date', coreHelpers.date); diff --git a/core/frontend/helpers/tpl/cancel_link.hbs b/core/frontend/helpers/tpl/cancel_link.hbs new file mode 100644 index 0000000000..0d0a293f28 --- /dev/null +++ b/core/frontend/helpers/tpl/cancel_link.hbs @@ -0,0 +1,11 @@ +{{#if cancel_at_period_end}} + + {{continueLabel}} + +{{else}} + + {{cancelLabel}} + +{{/if}} + + diff --git a/core/server/data/migrations/versions/3.2/01-add-cancel-at-period-end-to-subscriptions.js b/core/server/data/migrations/versions/3.2/01-add-cancel-at-period-end-to-subscriptions.js new file mode 100644 index 0000000000..1065a01e1c --- /dev/null +++ b/core/server/data/migrations/versions/3.2/01-add-cancel-at-period-end-to-subscriptions.js @@ -0,0 +1,25 @@ +const commands = require('../../../schema').commands; + +module.exports.up = commands.createColumnMigration({ + table: 'members_stripe_customers_subscriptions', + column: 'cancel_at_period_end', + dbIsInCorrectState(columnExists) { + return columnExists === true; + }, + operation: commands.addColumn, + operationVerb: 'Adding' +}); + +module.exports.down = commands.createColumnMigration({ + table: 'members_stripe_customers_subscriptions', + column: 'cancel_at_period_end', + dbIsInCorrectState(columnExists) { + return columnExists === false; + }, + operation: commands.dropColumn, + operationVerb: 'Removing' +}); + +module.exports.config = { + transaction: true +}; diff --git a/core/server/data/schema/schema.js b/core/server/data/schema/schema.js index 1259c33ae9..aadf4e69bd 100644 --- a/core/server/data/schema/schema.js +++ b/core/server/data/schema/schema.js @@ -351,6 +351,7 @@ module.exports = { subscription_id: {type: 'string', maxlength: 255, nullable: false, unique: false}, plan_id: {type: 'string', maxlength: 255, nullable: false, unique: false}, status: {type: 'string', maxlength: 50, nullable: false}, + cancel_at_period_end: {type: 'bool', nullable: false, defaultTo: false}, current_period_end: {type: 'dateTime', nullable: false}, start_date: {type: 'dateTime', nullable: false}, default_payment_card_last4: {type: 'string', maxlength: 4, nullable: true}, diff --git a/core/server/public/members.js b/core/server/public/members.js index edf3f1baf7..47ee3e3591 100644 --- a/core/server/public/members.js +++ b/core/server/public/members.js @@ -133,6 +133,106 @@ Array.prototype.forEach.call(document.querySelectorAll('[data-members-signout]') el.addEventListener('click', clickHandler); }); +Array.prototype.forEach.call(document.querySelectorAll('[data-members-cancel-subscription]'), function (el) { + var errorEl = el.parentElement.querySelector('[data-members-error]'); + function clickHandler(event) { + el.removeEventListener('click', clickHandler); + event.preventDefault(); + el.classList.remove('error'); + el.classList.add('loading'); + + var subscriptionId = el.dataset.membersCancelSubscription; + + if (errorEl) { + errorEl.innerText = ''; + } + + return fetch('{{blog-url}}/members/ssr', { + credentials: 'same-origin' + }).then(function (res) { + if (!res.ok) { + return null; + } + + return res.text(); + }).then(function (identity) { + return fetch(`{{admin-url}}/api/canary/members/subscriptions/${subscriptionId}/`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + identity: identity, + cancel_at_period_end: true + }) + }); + }).then(function (res) { + if (res.ok) { + window.location.reload(); + } else { + el.addEventListener('click', clickHandler); + el.classList.remove('loading'); + el.classList.add('error'); + + if (errorEl) { + errorEl.innerText = 'There was an error cancelling your subscription, please try again.'; + } + } + }); + } + el.addEventListener('click', clickHandler); +}); + +Array.prototype.forEach.call(document.querySelectorAll('[data-members-continue-subscription]'), function (el) { + var errorEl = el.parentElement.querySelector('[data-members-error]'); + function clickHandler(event) { + el.removeEventListener('click', clickHandler); + event.preventDefault(); + el.classList.remove('error'); + el.classList.add('loading'); + + var subscriptionId = el.dataset.membersContinueSubscription; + + if (errorEl) { + errorEl.innerText = ''; + } + + return fetch('{{blog-url}}/members/ssr', { + credentials: 'same-origin' + }).then(function (res) { + if (!res.ok) { + return null; + } + + return res.text(); + }).then(function (identity) { + return fetch(`{{admin-url}}/api/canary/members/subscriptions/${subscriptionId}/`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + identity: identity, + cancel_at_period_end: false + }) + }); + }).then(function (res) { + if (res.ok) { + window.location.reload(); + } else { + el.addEventListener('click', clickHandler); + el.classList.remove('loading'); + el.classList.add('error'); + + if (errorEl) { + errorEl.innerText = 'There was an error continuing your subscription, please try again.'; + } + } + }); + } + el.addEventListener('click', clickHandler); +}); + var url = new URL(window.location); if (url.searchParams.get('token')) { url.searchParams.delete('token'); diff --git a/core/server/translations/en.json b/core/server/translations/en.json index d4910365e7..d50047debf 100644 --- a/core/server/translations/en.json +++ b/core/server/translations/en.json @@ -565,6 +565,9 @@ "asset": { "pathIsRequired": "The \\{\\{asset\\}\\} helper must be passed a path" }, + "cancel_link": { + "invalidData": "The \\{\\{cancel_link\\}\\} helper was used outside of a subscription context. See https://ghost.org/docs/api/handlebars-themes/helpers/cancel_link/." + }, "foreach": { "iteratorNeeded": "Need to pass an iterator to #foreach" }, diff --git a/core/server/web/api/canary/members/app.js b/core/server/web/api/canary/members/app.js index 8d71da6cf1..460677f2da 100644 --- a/core/server/web/api/canary/members/app.js +++ b/core/server/web/api/canary/members/app.js @@ -21,6 +21,7 @@ module.exports = function setupMembersApiApp() { // NOTE: this is wrapped in a function to ensure we always go via the getter apiApp.post('/send-magic-link', (req, res, next) => membersService.api.middleware.sendMagicLink(req, res, next)); apiApp.post('/create-stripe-checkout-session', (req, res, next) => membersService.api.middleware.createCheckoutSession(req, res, next)); + apiApp.put('/subscriptions/:id', (req, res, next) => membersService.api.middleware.updateSubscription(req, res, next)); // API error handling apiApp.use(shared.middlewares.errorHandler.resourceNotFound); diff --git a/core/test/unit/data/schema/integrity_spec.js b/core/test/unit/data/schema/integrity_spec.js index 470de5d541..f40c3e2fe9 100644 --- a/core/test/unit/data/schema/integrity_spec.js +++ b/core/test/unit/data/schema/integrity_spec.js @@ -19,7 +19,7 @@ var should = require('should'), */ describe('DB version integrity', function () { // Only these variables should need updating - const currentSchemaHash = '773f8f6cd4267f50aec6af8c8b1edbd2'; + const currentSchemaHash = '3ec33e7039a21dba597ada2a03de0526'; const currentFixturesHash = '1a0f96fa1d8b976d663eb06719be031c'; // If this test is failing, then it is likely a change has been made that requires a DB version bump, diff --git a/core/test/unit/helpers/cancel_link_spec.js b/core/test/unit/helpers/cancel_link_spec.js new file mode 100644 index 0000000000..84c55b2b66 --- /dev/null +++ b/core/test/unit/helpers/cancel_link_spec.js @@ -0,0 +1,113 @@ +const should = require('should'); +const hbs = require('../../../frontend/services/themes/engine'); +const helpers = require('../../../frontend/helpers'); +const configUtils = require('../../utils/configUtils'); + +describe('{{cancel_link}} helper', function () { + before(function (done) { + hbs.express4({partialsDir: [configUtils.config.get('paths').helperTemplates]}); + + hbs.cachePartials(function () { + done(); + }); + }); + + const defaultLinkClass = /class="gh-subscription-cancel"/; + const defaultErrorElementClass = /class="gh-error gh-error-subscription-cancel"/; + const defaultCancelLinkText = /Cancel subscription/; + const defaultContinueLinkText = /Continue subscription/; + + it('should throw if subscription data is incorrect', function () { + var runHelper = function (data) { + return function () { + helpers.cancel_link.call(data); + }; + }, expectedMessage = 'The {{cancel_link}} helper was used outside of a subscription context. See https://ghost.org/docs/api/handlebars-themes/helpers/cancel_link/.'; + + runHelper('not an object').should.throwError(expectedMessage); + runHelper(function () { + }).should.throwError(expectedMessage); + }); + + it('can render cancel subscription link', function () { + const rendered = helpers.cancel_link.call({ + id: 'sub_cancel', + cancel_at_period_end: false + }); + should.exist(rendered); + + rendered.string.should.match(defaultLinkClass); + rendered.string.should.match(/data-members-cancel-subscription="sub_cancel"/); + rendered.string.should.match(defaultCancelLinkText); + + rendered.string.should.match(defaultErrorElementClass); + }); + + it('can render continue subscription link', function () { + const rendered = helpers.cancel_link.call({ + id: 'sub_continue', + cancel_at_period_end: true + }); + should.exist(rendered); + + rendered.string.should.match(defaultLinkClass); + rendered.string.should.match(/data-members-continue-subscription="sub_continue"/); + rendered.string.should.match(defaultContinueLinkText); + }); + + it('can render custom link class', function () { + const rendered = helpers.cancel_link.call({ + id: 'sub_cancel', + cancel_at_period_end: false + }, { + hash: { + class: 'custom-link-class' + } + }); + should.exist(rendered); + + rendered.string.should.match(/custom-link-class/); + }); + + it('can render custom error class', function () { + const rendered = helpers.cancel_link.call({ + id: 'sub_cancel', + cancel_at_period_end: false + }, { + hash: { + errorClass: 'custom-error-class' + } + }); + should.exist(rendered); + + rendered.string.should.match(/custom-error-class/); + }); + + it('can render custom cancel subscription link attributes', function () { + const rendered = helpers.cancel_link.call({ + id: 'sub_cancel', + cancel_at_period_end: false + }, { + hash: { + cancelLabel: 'custom cancel link text' + } + }); + should.exist(rendered); + + rendered.string.should.match(/custom cancel link text/); + }); + + it('can render custom continue subscription link attributes', function () { + const rendered = helpers.cancel_link.call({ + id: 'sub_cancel', + cancel_at_period_end: true + }, { + hash: { + continueLabel: 'custom continue link text' + } + }); + should.exist(rendered); + + rendered.string.should.match(/custom continue link text/); + }); +}); diff --git a/core/test/unit/helpers/get_spec.js b/core/test/unit/helpers/get_spec.js index 7508a55a82..fa20db11c5 100644 --- a/core/test/unit/helpers/get_spec.js +++ b/core/test/unit/helpers/get_spec.js @@ -5,9 +5,7 @@ var should = require('should'), // Stuff we are testing helpers = require('../../../frontend/helpers'), models = require('../../../server/models'), - api = require('../../../server/api'), - - labs = require('../../../server/services/labs'); + api = require('../../../server/api'); describe('{{#get}} helper', function () { var fn, inverse; diff --git a/core/test/unit/helpers/index_spec.js b/core/test/unit/helpers/index_spec.js index 56d277cdc5..587c210639 100644 --- a/core/test/unit/helpers/index_spec.js +++ b/core/test/unit/helpers/index_spec.js @@ -8,7 +8,7 @@ var should = require('should'), describe('Helpers', function () { var hbsHelpers = ['each', 'if', 'unless', 'with', 'helperMissing', 'blockHelperMissing', 'log', 'lookup', 'block', 'contentFor'], ghostHelpers = [ - 'asset', 'author', 'authors', 'body_class', 'concat', 'content', 'date', 'encode', 'excerpt', 'facebook_url', 'foreach', 'get', + 'asset', 'author', 'authors', 'body_class', 'cancel_link', 'concat', 'content', 'date', 'encode', 'excerpt', 'facebook_url', 'foreach', 'get', 'ghost_foot', 'ghost_head', 'has', 'img_url', 'is', 'lang', 'link', 'link_class', 'meta_description', 'meta_title', 'navigation', 'next_post', 'page_url', 'pagination', 'plural', 'post_class', 'prev_post', 'reading_time', 't', 'tags', 'title', 'twitter_url', 'url' diff --git a/package.json b/package.json index b376f08b54..cdc204bfc6 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "dependencies": { "@nexes/nql": "0.3.0", "@tryghost/helpers": "1.1.19", - "@tryghost/members-api": "0.10.1", + "@tryghost/members-api": "0.10.2", "@tryghost/members-ssr": "0.7.3", "@tryghost/social-urls": "0.1.4", "@tryghost/string": "^0.1.3", @@ -80,7 +80,7 @@ "ghost-storage-base": "0.0.3", "glob": "7.1.6", "got": "9.6.0", - "gscan": "3.1.1", + "gscan": "3.2.0", "html-to-text": "5.1.1", "image-size": "0.8.3", "intl": "1.2.5", diff --git a/yarn.lock b/yarn.lock index 3155f1cccf..5f83e5645c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -237,10 +237,10 @@ jsonwebtoken "^8.5.1" lodash "^4.17.15" -"@tryghost/members-api@0.10.1": - version "0.10.1" - resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-0.10.1.tgz#07b37df38fb937ae99d491cb308bc2f18a2dfaf7" - integrity sha512-38dA8nVh3UTSJEDYKNKy98sQArokTrUoHTELEc2HddCrEhAkfezghQEDLikty8RQ9IQs4XpKh3q7JO4bQbK6Ww== +"@tryghost/members-api@0.10.2": + version "0.10.2" + resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-0.10.2.tgz#c28d83a7e9817310a6e7a843f8a2a4669b52856e" + integrity sha512-dbA/NO6fpIxDu+b6tH3TN3i2tUrCGnue4vvOuSm0tctEWOTSIceaaiS64HFpncrsJ8obqvqY6ekilgASQWnZ1w== dependencies: "@tryghost/magic-link" "^0.3.2" bluebird "^3.5.4" @@ -2400,25 +2400,25 @@ error@^7.0.0: string-template "~0.2.1" es-abstract@^1.5.1: - version "1.16.0" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.16.0.tgz#d3a26dc9c3283ac9750dca569586e976d9dcc06d" - integrity sha512-xdQnfykZ9JMEiasTAJZJdMWCQ1Vm00NBw79/AWi7ELfZuuPCSOMDZbT9mkOfSctVtfhb+sAAzrm+j//GjjLHLg== + version "1.16.3" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.16.3.tgz#52490d978f96ff9f89ec15b5cf244304a5bca161" + integrity sha512-WtY7Fx5LiOnSYgF5eg/1T+GONaGmpvpPdCpSnYij+U2gDTL0UPfWrhDw7b2IYb+9NQJsYpCA0wOQvZfsd6YwRw== dependencies: - es-to-primitive "^1.2.0" + es-to-primitive "^1.2.1" function-bind "^1.1.1" has "^1.0.3" - has-symbols "^1.0.0" + has-symbols "^1.0.1" is-callable "^1.1.4" is-regex "^1.0.4" - object-inspect "^1.6.0" + object-inspect "^1.7.0" object-keys "^1.1.1" string.prototype.trimleft "^2.1.0" string.prototype.trimright "^2.1.0" -es-to-primitive@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.0.tgz#edf72478033456e8dda8ef09e00ad9650707f377" - integrity sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg== +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== dependencies: is-callable "^1.1.4" is-date-object "^1.0.1" @@ -3837,10 +3837,10 @@ grunt@1.0.4: path-is-absolute "~1.0.0" rimraf "~2.6.2" -gscan@3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/gscan/-/gscan-3.1.1.tgz#e86c2b0f93df7b1f85452584d3dbcb0b766bf901" - integrity sha512-As5E9ghdLfEmp+JjFcZhkkPNPzsLNgMj0r/CVkgwcHcQ1rTu4WhLKH3FsZIZPObCwqWjNxJeMTU+Tf4hrDKXfw== +gscan@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/gscan/-/gscan-3.2.0.tgz#c3d237bd1db5df35fab9155191ede6caf05755b6" + integrity sha512-6M/Vtw9ko732zESJBhVforEFiCM6pbSMX6FGjOCf4NGYiEYB00LaHNczaxbhNuEa25usRGexM2AO9vFM3uP4TQ== dependencies: "@tryghost/extract-zip" "1.6.6" "@tryghost/pretty-cli" "1.2.2" @@ -3925,10 +3925,10 @@ has-flag@^3.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= -has-symbols@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44" - integrity sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q= +has-symbols@^1.0.0, has-symbols@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" + integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== has-unicode@^2.0.0: version "2.0.1" @@ -4588,11 +4588,11 @@ is-svg@^2.0.0: html-comment-regex "^1.1.0" is-symbol@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.2.tgz#a055f6ae57192caee329e7a860118b497a950f38" - integrity sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw== + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937" + integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ== dependencies: - has-symbols "^1.0.0" + has-symbols "^1.0.1" is-typedarray@~1.0.0: version "1.0.0" @@ -6322,10 +6322,10 @@ object-copy@^0.1.0: define-property "^0.2.5" kind-of "^3.0.3" -object-inspect@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.6.0.tgz#c70b6cbf72f274aab4c34c0c82f5167bf82cf15b" - integrity sha512-GJzfBZ6DgDAmnuaM3104jR4s1Myxr3Y3zfIyN4z3UdqN69oSRacNK8UhnobDdC+7J2AHCjGwxQubNJfE70SXXQ== +object-inspect@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67" + integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw== object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: version "1.1.1"