diff --git a/.github/scripts/dev.js b/.github/scripts/dev.js index 32b9e2f7c2..cae2f44523 100644 --- a/.github/scripts/dev.js +++ b/.github/scripts/dev.js @@ -41,7 +41,7 @@ const COMMAND_ADMIN = { const COMMAND_TYPESCRIPT = { name: 'ts', - command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions -- nx run \\$NX_PROJECT_NAME:build:ts', + command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/donations -- nx run \\$NX_PROJECT_NAME:build:ts', cwd: path.resolve(__dirname, '../../'), prefixColor: 'cyan', env: {} diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index e1c6f31609..6ab851dd8f 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -327,6 +327,7 @@ async function initServices({config}) { const mediaInliner = require('./server/services/media-inliner'); const collections = require('./server/services/collections'); const mailEvents = require('./server/services/mail-events'); + const donationService = require('./server/services/donations'); const urlUtils = require('./shared/url-utils'); @@ -365,7 +366,8 @@ async function initServices({config}) { slackNotifications.init(), collections.init(), mediaInliner.init(), - mailEvents.init() + mailEvents.init(), + donationService.init() ]); debug('End: Services'); diff --git a/ghost/core/core/server/models/donation-payment-event.js b/ghost/core/core/server/models/donation-payment-event.js new file mode 100644 index 0000000000..f770e544a6 --- /dev/null +++ b/ghost/core/core/server/models/donation-payment-event.js @@ -0,0 +1,34 @@ +const errors = require('@tryghost/errors'); +const ghostBookshelf = require('./base'); + +const DonationPaymentEvent = ghostBookshelf.Model.extend({ + tableName: 'donation_payment_events', + + member() { + return this.belongsTo('Member', 'member_id', 'id'); + }, + + postAttribution() { + return this.belongsTo('Post', 'attribution_id', 'id'); + }, + + userAttribution() { + return this.belongsTo('User', 'attribution_id', 'id'); + }, + + tagAttribution() { + return this.belongsTo('Tag', 'attribution_id', 'id'); + } +}, { + async edit() { + throw new errors.IncorrectUsageError({message: 'Cannot edit DonationPaymentEvent'}); + }, + + async destroy() { + throw new errors.IncorrectUsageError({message: 'Cannot destroy DonationPaymentEvent'}); + } +}); + +module.exports = { + DonationPaymentEvent: ghostBookshelf.model('DonationPaymentEvent', DonationPaymentEvent) +}; diff --git a/ghost/core/core/server/services/donations/DonationServiceWrapper.js b/ghost/core/core/server/services/donations/DonationServiceWrapper.js new file mode 100644 index 0000000000..ff921a07cd --- /dev/null +++ b/ghost/core/core/server/services/donations/DonationServiceWrapper.js @@ -0,0 +1,19 @@ +const {DonationPaymentEvent: DonationPaymentEventModel} = require('../../models'); + +class DonationServiceWrapper { + repository; + + init() { + if (this.repository) { + return; + } + + const {DonationBookshelfRepository} = require('@tryghost/donations'); + + this.repository = new DonationBookshelfRepository({ + DonationPaymentEventModel + }); + } +} + +module.exports = DonationServiceWrapper; diff --git a/ghost/core/core/server/services/donations/index.js b/ghost/core/core/server/services/donations/index.js new file mode 100644 index 0000000000..ab68e396fc --- /dev/null +++ b/ghost/core/core/server/services/donations/index.js @@ -0,0 +1,3 @@ +const DonationServiceWrapper = require('./DonationServiceWrapper'); + +module.exports = new DonationServiceWrapper(); diff --git a/ghost/core/core/server/services/members/api.js b/ghost/core/core/server/services/members/api.js index 5f2f73e33b..fbfdf26a44 100644 --- a/ghost/core/core/server/services/members/api.js +++ b/ghost/core/core/server/services/members/api.js @@ -214,7 +214,8 @@ function createApiInstance(config) { labsService: labsService, newslettersService: newslettersService, memberAttributionService: memberAttributionService.service, - emailSuppressionList + emailSuppressionList, + settingsCache }); return membersApiInstance; diff --git a/ghost/core/core/server/services/members/middleware.js b/ghost/core/core/server/services/members/middleware.js index 05e726e13a..16c78849ed 100644 --- a/ghost/core/core/server/services/members/middleware.js +++ b/ghost/core/core/server/services/members/middleware.js @@ -258,20 +258,28 @@ const createSessionFromMagicLink = async function (req, res, next) { } } - if (action === 'signin') { - const referrer = req.query.r; - const siteUrl = urlUtils.getSiteUrl(); + // If a custom referrer/redirect was passed, redirect the user to that URL + const referrer = req.query.r; + const siteUrl = urlUtils.getSiteUrl(); - if (referrer && referrer.startsWith(siteUrl)) { - const redirectUrl = new URL(referrer); - redirectUrl.searchParams.set('success', true); + if (referrer && referrer.startsWith(siteUrl)) { + const redirectUrl = new URL(referrer); + + // Copy search params + searchParams.forEach((value, key) => { + redirectUrl.searchParams.set(key, value); + }); + redirectUrl.searchParams.set('success', 'true'); + + if (action === 'signin') { + // Not sure if we can delete this, this is a legacy param redirectUrl.searchParams.set('action', 'signin'); - return res.redirect(redirectUrl.href); } + return res.redirect(redirectUrl.href); } // Do a standard 302 redirect to the homepage, with success=true - searchParams.set('success', true); + searchParams.set('success', 'true'); res.redirect(`${urlUtils.getSubdir()}/?${searchParams.toString()}`); } catch (err) { logging.warn(err.message); diff --git a/ghost/core/core/server/services/stripe/service.js b/ghost/core/core/server/services/stripe/service.js index 0c8cf174fb..59663ec670 100644 --- a/ghost/core/core/server/services/stripe/service.js +++ b/ghost/core/core/server/services/stripe/service.js @@ -9,6 +9,7 @@ const events = require('../../lib/common/events'); const models = require('../../models'); const {getConfig} = require('./config'); const settingsHelpers = require('../settings-helpers'); +const donationService = require('../donations'); async function configureApi() { const cfg = getConfig({settingsHelpers, config, urlUtils}); @@ -54,7 +55,8 @@ module.exports = new StripeService({ value: data.secret }]); } - } + }, + donationService }); module.exports.init = async function init() { diff --git a/ghost/core/package.json b/ghost/core/package.json index b6f939b047..26597e5354 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -80,6 +80,7 @@ "@tryghost/database-info": "0.3.17", "@tryghost/debug": "0.1.24", "@tryghost/domain-events": "0.0.0", + "@tryghost/donations": "0.0.0", "@tryghost/dynamic-routing-events": "0.0.0", "@tryghost/email-analytics-provider-mailgun": "0.0.0", "@tryghost/email-analytics-service": "0.0.0", diff --git a/ghost/core/test/unit/server/services/members/middleware.test.js b/ghost/core/test/unit/server/services/members/middleware.test.js index b1c3a210e0..cca19f1411 100644 --- a/ghost/core/test/unit/server/services/members/middleware.test.js +++ b/ghost/core/test/unit/server/services/members/middleware.test.js @@ -144,7 +144,7 @@ describe('Members Service Middleware', function () { res.redirect.firstCall.args[0].should.eql('https://custom.com/paid/'); }); - it('redirects member to referrer param path on signup if it is on the site', async function () { + it('redirects member to referrer param path on signin if it is on the site', async function () { req.url = '/members?token=test&action=signin&r=https%3A%2F%2Fsite.com%2Fblah%2Fmy-post%2F'; req.query = {token: 'test', action: 'signin', r: 'https://site.com/blah/my-post/#comment-123'}; @@ -157,7 +157,23 @@ describe('Members Service Middleware', function () { // Check behavior next.calledOnce.should.be.false(); res.redirect.calledOnce.should.be.true(); - res.redirect.firstCall.args[0].should.eql('https://site.com/blah/my-post/?success=true&action=signin#comment-123'); + res.redirect.firstCall.args[0].should.eql('https://site.com/blah/my-post/?action=signin&success=true#comment-123'); + }); + + it('redirects member to referrer param path on signup if it is on the site', async function () { + req.url = '/members?token=test&action=signup&r=https%3A%2F%2Fsite.com%2Fblah%2Fmy-post%2F'; + req.query = {token: 'test', action: 'signup', r: 'https://site.com/blah/my-post/#comment-123'}; + + // Fake token handling failure + membersService.ssr.exchangeTokenForSession.resolves({}); + + // Call the middleware + await membersMiddleware.createSessionFromMagicLink(req, res, next); + + // Check behavior + next.calledOnce.should.be.false(); + res.redirect.calledOnce.should.be.true(); + res.redirect.firstCall.args[0].should.eql('https://site.com/blah/my-post/?action=signup&success=true#comment-123'); }); it('does not redirect to referrer param if it is external', async function () { diff --git a/ghost/donations/.eslintrc.js b/ghost/donations/.eslintrc.js new file mode 100644 index 0000000000..cb690be63f --- /dev/null +++ b/ghost/donations/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/ts' + ] +}; diff --git a/ghost/donations/README.md b/ghost/donations/README.md new file mode 100644 index 0000000000..1ef42a1ec8 --- /dev/null +++ b/ghost/donations/README.md @@ -0,0 +1,21 @@ +# Donations + + +## Usage + + +## Develop + +This is a monorepo package. + +Follow the instructions for the top-level repo. +1. `git clone` this repo & `cd` into it as usual +2. Run `yarn` to install top-level dependencies. + + + +## Test + +- `yarn lint` run just eslint +- `yarn test` run lint and tests + diff --git a/ghost/donations/package.json b/ghost/donations/package.json new file mode 100644 index 0000000000..a6e375e618 --- /dev/null +++ b/ghost/donations/package.json @@ -0,0 +1,32 @@ +{ + "name": "@tryghost/donations", + "version": "0.0.0", + "repository": "https://github.com/TryGhost/Ghost/tree/main/packages/donations", + "author": "Ghost Foundation", + "private": true, + "main": "build/index.js", + "types": "build/index.d.ts", + "scripts": { + "dev": "tsc --watch --preserveWatchOutput --sourceMap", + "build": "tsc", + "build:ts": "yarn build", + "prepare": "tsc", + "test:unit": "NODE_ENV=testing c8 --src src --all --reporter text --reporter cobertura mocha -r ts-node/register './test/**/*.test.ts'", + "test": "yarn test:types && yarn test:unit", + "test:types": "tsc --noEmit", + "lint:code": "eslint src/ --ext .ts --cache", + "lint": "yarn lint:code && yarn lint:test", + "lint:test": "eslint -c test/.eslintrc.js test/ --ext .ts --cache" + }, + "files": [ + "build" + ], + "devDependencies": { + "c8": "8.0.1", + "mocha": "10.2.0", + "sinon": "15.2.0", + "ts-node": "10.9.1", + "typescript": "5.1.6" + }, + "dependencies": {} +} diff --git a/ghost/donations/src/DonationBookshelfRepository.ts b/ghost/donations/src/DonationBookshelfRepository.ts new file mode 100644 index 0000000000..1e1c9b6c5b --- /dev/null +++ b/ghost/donations/src/DonationBookshelfRepository.ts @@ -0,0 +1,48 @@ +import {DonationPaymentEvent} from './DonationPaymentEvent'; +import {DonationRepository} from './DonationRepository'; + +type BookshelfModelInstance = unknown; +type BookshelfOptions = unknown; +type BookshelfModel = { + add(data: Partial, unfilteredOptions?: BookshelfOptions): Promise; +}; +type DonationEventModelInstance = BookshelfModelInstance & { + name: string | null; + email: string; + member_id: string | null; + amount: number; + currency: string; + + attribution_id: string | null; + attribution_url: string | null; + attribution_type: string | null; + referrer_source: string | null; + referrer_medium: string | null; + referrer_url: string | null; +} +type DonationPaymentEventModel = BookshelfModel; + +export class DonationBookshelfRepository implements DonationRepository { + #Model: DonationPaymentEventModel; + + constructor({DonationPaymentEventModel}: {DonationPaymentEventModel: DonationPaymentEventModel}) { + this.#Model = DonationPaymentEventModel; + } + + async save(event: DonationPaymentEvent) { + await this.#Model.add({ + name: event.name, + email: event.email, + member_id: event.memberId, + amount: event.amount, + currency: event.currency, + + attribution_id: event.attributionId, + attribution_url: event.attributionUrl, + attribution_type: event.attributionType, + referrer_source: event.referrerSource, + referrer_medium: event.referrerMedium, + referrer_url: event.referrerUrl, + }); + } +} diff --git a/ghost/donations/src/DonationPaymentEvent.ts b/ghost/donations/src/DonationPaymentEvent.ts new file mode 100644 index 0000000000..1202744ccd --- /dev/null +++ b/ghost/donations/src/DonationPaymentEvent.ts @@ -0,0 +1,39 @@ +export class DonationPaymentEvent { + timestamp: Date; + name: string | null; + email: string; + memberId: string | null; + amount: number; + currency: string; + + attributionId: string | null; + attributionUrl: string | null; + attributionType: string | null; + referrerSource: string | null; + referrerMedium: string | null; + referrerUrl: string | null; + + constructor(data: Omit, timestamp: Date) { + this.timestamp = timestamp; + + this.name = data.name; + this.email = data.email; + this.memberId = data.memberId; + this.amount = data.amount; + this.currency = data.currency; + + this.attributionId = data.attributionId; + this.attributionUrl = data.attributionUrl; + this.attributionType = data.attributionType; + this.referrerSource = data.referrerSource; + this.referrerMedium = data.referrerMedium; + this.referrerUrl = data.referrerUrl; + } + + static create(data: Omit, timestamp?: Date) { + return new DonationPaymentEvent( + data, + timestamp ?? new Date() + ); + } +} diff --git a/ghost/donations/src/DonationRepository.ts b/ghost/donations/src/DonationRepository.ts new file mode 100644 index 0000000000..769d9ce15b --- /dev/null +++ b/ghost/donations/src/DonationRepository.ts @@ -0,0 +1,5 @@ +import {DonationPaymentEvent} from "./DonationPaymentEvent"; + +export type DonationRepository = { + save(event: DonationPaymentEvent): Promise; +} diff --git a/ghost/donations/src/index.ts b/ghost/donations/src/index.ts new file mode 100644 index 0000000000..21c54fc13a --- /dev/null +++ b/ghost/donations/src/index.ts @@ -0,0 +1,3 @@ +export * from './DonationPaymentEvent'; +export * from './DonationRepository'; +export * from './DonationBookshelfRepository'; diff --git a/ghost/donations/test/.eslintrc.js b/ghost/donations/test/.eslintrc.js new file mode 100644 index 0000000000..6fe6dc1504 --- /dev/null +++ b/ghost/donations/test/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + parser: '@typescript-eslint/parser', + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test' + ] +}; diff --git a/ghost/donations/test/hello.test.ts b/ghost/donations/test/hello.test.ts new file mode 100644 index 0000000000..e66b88fad4 --- /dev/null +++ b/ghost/donations/test/hello.test.ts @@ -0,0 +1,8 @@ +import assert from 'assert/strict'; + +describe('Hello world', function () { + it('Runs a test', function () { + // TODO: Write me! + assert.ok(require('../')); + }); +}); diff --git a/ghost/donations/tsconfig.json b/ghost/donations/tsconfig.json new file mode 100644 index 0000000000..b0d3bce467 --- /dev/null +++ b/ghost/donations/tsconfig.json @@ -0,0 +1,110 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": ["es2019"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + "rootDir": "src", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "build", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + }, + "include": ["src/**/*"] +} diff --git a/ghost/magic-link/lib/MagicLink.js b/ghost/magic-link/lib/MagicLink.js index a46b23b88b..b418222605 100644 --- a/ghost/magic-link/lib/MagicLink.js +++ b/ghost/magic-link/lib/MagicLink.js @@ -53,7 +53,7 @@ class MagicLink { * @param {string} options.email - The email to send magic link to * @param {TokenData} options.tokenData - The data for token * @param {string} [options.type='signin'] - The type to be passed to the url and content generator functions - * @param {string} [options.referrer=null] - The referrer of the request, if exists + * @param {string} [options.referrer=null] - The referrer of the request, if exists. The member will be redirected back to this URL after signin. * @returns {Promise<{token: Token, info: SentMessageInfo}>} */ async sendMagicLink(options) { @@ -84,13 +84,14 @@ class MagicLink { * @param {object} options * @param {TokenData} options.tokenData - The data for token * @param {string} [options.type='signin'] - The type to be passed to the url and content generator functions. This type will also get stored in the token data. + * @param {string} [options.referrer=null] - The referrer of the request, if exists. The member will be redirected back to this URL after signin. * @returns {Promise} - signin URL */ async getMagicLink(options) { const type = options.type ?? 'signin'; const token = await this.tokenProvider.create({...options.tokenData, type}); - return this.getSigninURL(token, type); + return this.getSigninURL(token, type, options.referrer); } /** diff --git a/ghost/members-api/lib/controllers/RouterController.js b/ghost/members-api/lib/controllers/RouterController.js index fc3fd29cbe..4e168bc758 100644 --- a/ghost/members-api/lib/controllers/RouterController.js +++ b/ghost/members-api/lib/controllers/RouterController.js @@ -14,7 +14,8 @@ const messages = { unableToCheckout: 'Unable to initiate checkout session', inviteOnly: 'This site is invite-only, contact the owner for access.', memberNotFound: 'No member exists with this e-mail address.', - memberNotFoundSignUp: 'No member exists with this e-mail address. Please sign up first.' + memberNotFoundSignUp: 'No member exists with this e-mail address. Please sign up first.', + invalidType: 'Invalid checkout type.' }; module.exports = class RouterController { @@ -139,93 +140,14 @@ module.exports = class RouterController { res.end(JSON.stringify(sessionInfo)); } - async createCheckoutSession(req, res) { - let ghostPriceId = req.body.priceId; - const tierId = req.body.tierId; - let cadence = req.body.cadence; - const identity = req.body.identity; - const offerId = req.body.offerId; - const metadata = req.body.metadata ?? {}; - - if (!ghostPriceId && !offerId && !tierId && !cadence) { - throw new BadRequestError({ - message: tpl(messages.badRequest) - }); - } - - if (offerId && (ghostPriceId || (tierId && cadence))) { - throw new BadRequestError({ - message: tpl(messages.badRequest) - }); - } - - if (ghostPriceId && tierId && cadence) { - throw new BadRequestError({ - message: tpl(messages.badRequest) - }); - } - - if (tierId && !cadence) { - throw new BadRequestError({ - message: tpl(messages.badRequest) - }); - } - - if (cadence && cadence !== 'month' && cadence !== 'year') { - throw new BadRequestError({ - message: tpl(messages.badRequest) - }); - } - - let tier; - let offer; - let member; - let options = {}; - - if (offerId) { - offer = await this._offersAPI.getOffer({id: offerId}); - tier = await this._tiersService.api.read(offer.tier.id); - cadence = offer.cadence; - // Attach offer information to stripe metadata for free trial offers - // free trial offers don't have associated stripe coupons - metadata.offer = offer.id; - } else { - offer = null; - tier = await this._tiersService.api.read(tierId); - } - - if (tier.status === 'archived') { - throw new NoPermissionError({ - message: tpl(messages.tierArchived) - }); - } - - if (identity) { - try { - const claims = await this._tokenService.decodeToken(identity); - const email = claims && claims.sub; - if (email) { - member = await this._memberRepository.get({ - email - }, { - withRelated: ['stripeCustomers', 'products'] - }); - } - } catch (err) { - throw new UnauthorizedError({err}); - } - } else if (req.body.customerEmail) { - member = await this._memberRepository.get({ - email: req.body.customerEmail - }, { - withRelated: ['stripeCustomers', 'products'] - }); - } - + async _setAttributionMetadata(metadata) { // Don't allow to set the source manually delete metadata.attribution_id; delete metadata.attribution_url; delete metadata.attribution_type; + delete metadata.referrer_source; + delete metadata.referrer_medium; + delete metadata.referrer_url; if (metadata.urlHistory) { // The full attribution history doesn't fit in the Stripe metadata (can't store objects + limited to 50 keys and 500 chars values) @@ -260,31 +182,121 @@ module.exports = class RouterController { metadata.referrer_url = attribution.referrerUrl; } } + } - options.successUrl = req.body.successUrl; - options.cancelUrl = req.body.cancelUrl; - options.email = req.body.customerEmail; + /** + * Read the passed tier, offer and cadence from the request body and return the corresponding objects, or throws if validation fails + * @returns + */ + async _getSubscriptionCheckoutData(body) { + const ghostPriceId = body.priceId; + const tierId = body.tierId; + const offerId = body.offerId; - if (!member && req.body.customerEmail && !req.body.successUrl) { - options.successUrl = await this._magicLinkService.getMagicLink({ - tokenData: { - email: req.body.customerEmail, - attribution: { - id: metadata.attribution_id ?? null, - type: metadata.attribution_type ?? null, - url: metadata.attribution_url ?? null - } - }, - type: 'signup' + let cadence = body.cadence; + let tier; + let offer; + + // Validate basic input + if (!ghostPriceId && !offerId && !tierId && !cadence) { + throw new BadRequestError({ + message: tpl(messages.badRequest) }); } - const restrictCheckout = member?.get('status') === 'paid'; + if (offerId && (ghostPriceId || (tierId && cadence))) { + throw new BadRequestError({ + message: tpl(messages.badRequest) + }); + } + + if (ghostPriceId && tierId && cadence) { + throw new BadRequestError({ + message: tpl(messages.badRequest) + }); + } + + if (tierId && !cadence) { + throw new BadRequestError({ + message: tpl(messages.badRequest) + }); + } + + if (cadence && cadence !== 'month' && cadence !== 'year') { + throw new BadRequestError({ + message: tpl(messages.badRequest) + }); + } + + // Fetch tier and offer + if (offerId) { + offer = await this._offersAPI.getOffer({id: offerId}); + tier = await this._tiersService.api.read(offer.tier.id); + cadence = offer.cadence; + } else { + offer = null; + tier = await this._tiersService.api.read(tierId); + } + + if (tier.status === 'archived') { + throw new NoPermissionError({ + message: tpl(messages.tierArchived) + }); + } + + return { + tier, + offer, + cadence + }; + } + + /** + * + * @param {object} options + * @param {object} options.tier + * @param {object} [options.offer] + * @param {string} options.cadence + * @param {string} options.successUrl URL to redirect to after successful checkout + * @param {string} options.cancelUrl URL to redirect to after cancelled checkout + * @param {string} [options.email] Email address of the customer + * @param {object} [options.member] Currently authenticated member OR member associated with the email address + * @param {boolean} options.isAuthenticated + * @param {object} options.metadata Metadata to be passed to Stripe + * @returns + */ + async _createSubscriptionCheckoutSession(options) { + if (options.offer) { + // Attach offer information to stripe metadata for free trial offers + // free trial offers don't have associated stripe coupons + options.metadata.offer = options.offer.id; + } + + if (!options.member && options.email) { + // Create a signup link if there is no member with this email address + options.successUrl = await this._magicLinkService.getMagicLink({ + tokenData: { + email: options.email, + attribution: { + id: options.metadata.attribution_id ?? null, + type: options.metadata.attribution_type ?? null, + url: options.metadata.attribution_url ?? null + } + }, + type: 'signup', + // Redirect to the original success url after sign up + referrer: options.successUrl + }); + } + + const restrictCheckout = options.member?.get('status') === 'paid'; if (restrictCheckout) { - if (!identity && req.body.customerEmail) { + // This member is already subscribed to a paid tier + // We don't want to create a duplicate subscription + if (!options.isAuthenticated && options.email) { try { - await this._sendEmailWithMagicLink({email: req.body.customerEmail, requestedType: 'signin'}); + await this._sendEmailWithMagicLink({email: options.email, requestedType: 'signin'}); } catch (err) { logging.warn(err); } @@ -296,19 +308,9 @@ module.exports = class RouterController { } try { - const paymentLink = await this._paymentsService.getPaymentLink({ - tier, - cadence, - offer, - member, - metadata, - options - }); - res.writeHead(200, { - 'Content-Type': 'application/json' - }); + const paymentLink = await this._paymentsService.getPaymentLink(options); - return res.end(JSON.stringify({url: paymentLink})); + return {url: paymentLink}; } catch (err) { throw new BadRequestError({ err, @@ -317,6 +319,111 @@ module.exports = class RouterController { } } + /** + * + * @param {object} options + * @param {string} options.successUrl URL to redirect to after successful checkout + * @param {string} options.cancelUrl URL to redirect to after cancelled checkout + * @param {string} [options.email] Email address of the customer + * @param {object} [options.member] Currently authenticated member OR member associated with the email address + * @param {boolean} options.isAuthenticated + * @param {object} options.metadata Metadata to be passed to Stripe + * @returns + */ + async _createDonationCheckoutSession(options) { + try { + const paymentLink = await this._paymentsService.getDonationPaymentLink(options); + + return {url: paymentLink}; + } catch (err) { + throw new BadRequestError({ + err, + message: tpl(messages.unableToCheckout) + }); + } + } + + async createCheckoutSession(req, res) { + const type = req.body.type ?? 'subscription'; + const metadata = req.body.metadata ?? {}; + const identity = req.body.identity; + const membersEnabled = true; + + // Check this checkout type is supported + if (typeof type !== 'string' || !['subscription', 'donation'].includes(type)) { + throw new BadRequestError({ + message: tpl(messages.invalidType) + }); + } + + // Optional authentication + let member; + let isAuthenticated = false; + if (membersEnabled) { + if (identity) { + try { + const claims = await this._tokenService.decodeToken(identity); + const email = claims && claims.sub; + if (email) { + member = await this._memberRepository.get({ + email + }, { + withRelated: ['stripeCustomers', 'products'] + }); + isAuthenticated = true; + } + } catch (err) { + throw new UnauthorizedError({err}); + } + } else if (req.body.customerEmail) { + member = await this._memberRepository.get({ + email: req.body.customerEmail + }, { + withRelated: ['stripeCustomers', 'products'] + }); + } + } + + // Store attribution data in the metadata + await this._setAttributionMetadata(metadata); + + // Build options + const options = { + successUrl: req.body.successUrl, + cancelUrl: req.body.cancelUrl, + email: req.body.customerEmail, + member, + metadata, + isAuthenticated + }; + + let response; + if (type === 'subscription') { + if (!membersEnabled) { + throw new BadRequestError({ + message: tpl(messages.badRequest) + }); + } + + // Get selected tier, offer and cadence + const data = await this._getSubscriptionCheckoutData(req.body); + + // Check the checkout session + response = await this._createSubscriptionCheckoutSession({ + ...options, + ...data + }); + } else if (type === 'donation') { + response = await this._createDonationCheckoutSession(options); + } + + res.writeHead(200, { + 'Content-Type': 'application/json' + }); + + return res.end(JSON.stringify(response)); + } + async sendMagicLink(req, res) { const {email, autoRedirect} = req.body; let {emailType, redirect} = req.body; diff --git a/ghost/members-api/lib/members-api.js b/ghost/members-api/lib/members-api.js index 0774341cc4..854726fcb0 100644 --- a/ghost/members-api/lib/members-api.js +++ b/ghost/members-api/lib/members-api.js @@ -69,7 +69,8 @@ module.exports = function MembersAPI({ labsService, newslettersService, memberAttributionService, - emailSuppressionList + emailSuppressionList, + settingsCache }) { const tokenService = new TokenService({ privateKey, @@ -159,7 +160,8 @@ module.exports = function MembersAPI({ StripeCustomer, Offer, offersAPI, - stripeAPIService + stripeAPIService, + settingsCache }); const memberController = new MemberController({ diff --git a/ghost/payments/lib/PaymentsService.js b/ghost/payments/lib/PaymentsService.js index 11f55a681f..4dd06ade26 100644 --- a/ghost/payments/lib/PaymentsService.js +++ b/ghost/payments/lib/PaymentsService.js @@ -10,6 +10,7 @@ class PaymentsService { * @param {import('bookshelf').Model} deps.Offer * @param {import('@tryghost/members-offers/lib/application/OffersAPI')} deps.offersAPI * @param {import('@tryghost/members-stripe-service/lib/StripeAPI')} deps.stripeAPIService + * @param {{get(key: string): any}} deps.settingsCache */ constructor(deps) { /** @private */ @@ -24,6 +25,8 @@ class PaymentsService { this.offersAPI = deps.offersAPI; /** @private */ this.stripeAPIService = deps.stripeAPIService; + /** @private */ + this.settingsCache = deps.settingsCache; DomainEvents.subscribe(OfferCreatedEvent, async (event) => { await this.getCouponForOffer(event.data.offer.id); @@ -57,14 +60,13 @@ class PaymentsService { * @param {Offer} [params.offer] * @param {Member} [params.member] * @param {Object.} [params.metadata] - * @param {object} params.options - * @param {string} params.options.successUrl - * @param {string} params.options.cancelUrl - * @param {string} [params.options.email] + * @param {string} params.successUrl + * @param {string} params.cancelUrl + * @param {string} [params.email] * * @returns {Promise} */ - async getPaymentLink({tier, cadence, offer, member, metadata, options}) { + async getPaymentLink({tier, cadence, offer, member, metadata, successUrl, cancelUrl, email}) { let coupon = null; let trialDays = null; if (offer) { @@ -87,12 +89,10 @@ class PaymentsService { const price = await this.getPriceForTierCadence(tier, cadence); - const email = options.email || null; - const data = { metadata, - successUrl: options.successUrl, - cancelUrl: options.cancelUrl, + successUrl: successUrl, + cancelUrl: cancelUrl, trialDays: trialDays ?? tier.trialDays, coupon: coupon?.id }; @@ -111,6 +111,35 @@ class PaymentsService { return session.url; } + /** + * @param {object} params + * @param {Member} [params.member] + * @param {Object.} [params.metadata] + * @param {string} params.successUrl + * @param {string} params.cancelUrl + * @param {string} [params.email] + * + * @returns {Promise} + */ + async getDonationPaymentLink({member, metadata, successUrl, cancelUrl, email}) { + let customer = null; + if (member) { + customer = await this.getCustomerForMember(member); + } + + const data = { + priceId: (await this.getPriceForDonations()).id, + metadata, + successUrl: successUrl, + cancelUrl: cancelUrl, + customer, + customerEmail: !customer && email ? email : null + }; + + const session = await this.stripeAPIService.createDonationCheckoutSession(data); + return session.url; + } + async getCustomerForMember(member) { const rows = await this.StripeCustomerModel.where({ member_id: member.id @@ -206,6 +235,144 @@ class PaymentsService { } } + /** + * @returns {Promise<{id: string}>} + */ + async getProductForDonations({name}) { + const existingDonationPrices = await this.StripePriceModel + .where({ + type: 'donation' + }) + .query() + .select('stripe_product_id'); + + for (const row of existingDonationPrices) { + const product = await this.StripeProductModel + .where({ + stripe_product_id: row.stripe_product_id + }) + .query() + .select('stripe_product_id') + .first(); + + if (product) { + // Check active in Stripe + try { + const stripeProduct = await this.stripeAPIService.getProduct(row.stripe_product_id); + if (stripeProduct.active) { + return {id: stripeProduct.id}; + } + } catch (err) { + logging.warn(err); + } + } + } + + const product = await this.createProductForDonations({name}); + + return { + id: product.id + }; + } + + /** + * @returns {Promise<{id: string}>} + */ + async getPriceForDonations() { + const currency = 'usd'; // TODO: we need to use a setting here! + const nickname = 'Support ' + this.settingsCache.get('title'); + + const price = await this.StripePriceModel + .where({ + type: 'donation', + active: true, + currency + }) + .query() + .select('stripe_price_id', 'stripe_product_id', 'id', 'nickname') + .first(); + + if (price) { + if (price.nickname !== nickname) { + // Rename it in Stripe (in case the publication name changed) + try { + await this.stripeAPIService.updatePrice(price.stripe_price_id, { + nickname + }); + + // Update product too + await this.stripeAPIService.updateProduct(price.stripe_product_id, { + name: nickname + }); + + await this.StripePriceModel.edit({ + nickname + }, {id: price.id}); + } catch (err) { + logging.warn(err); + } + } + return { + id: price.stripe_price_id + }; + } + + const newPrice = await this.createPriceForDonations({ + nickname, + currency + }); + return { + id: newPrice.id + }; + } + + /** + * @returns {Promise} + */ + async createPriceForDonations({currency, nickname}) { + const product = await this.getProductForDonations({name: nickname}); + + // Create the price in Stripe + const price = await this.stripeAPIService.createPrice({ + currency, + product: product.id, + custom_unit_amount: { + enabled: true + }, + nickname, + type: 'one-time', + active: true + }); + + // Save it to the database + await this.StripePriceModel.add({ + stripe_price_id: price.id, + stripe_product_id: product.id, + active: price.active, + nickname: price.nickname, + currency: price.currency, + amount: 0, + type: 'donation', + interval: null + }); + return price; + } + + /** + * @returns {Promise} + */ + async createProductForDonations({name}) { + const product = await this.stripeAPIService.createProduct({ + name + }); + + await this.StripeProductModel.add({ + product_id: null, + stripe_product_id: product.id + }); + return product; + } + /** * @param {import('@tryghost/tiers').Tier} tier * @param {'month'|'year'} cadence @@ -220,7 +387,8 @@ class PaymentsService { currency, interval: cadence, amount, - active: true + active: true, + type: 'recurring' }).query().select('id', 'stripe_price_id'); for (const row of rows) { diff --git a/ghost/stripe/lib/StripeAPI.js b/ghost/stripe/lib/StripeAPI.js index ac9ff60009..34caa99bb2 100644 --- a/ghost/stripe/lib/StripeAPI.js +++ b/ghost/stripe/lib/StripeAPI.js @@ -1,6 +1,9 @@ +// @ts-ignore const {VersionMismatchError} = require('@tryghost/errors'); +// @ts-ignore const debug = require('@tryghost/debug')('stripe'); const Stripe = require('stripe').Stripe; +// @ts-ignore const LeakyBucket = require('leaky-bucket'); /* Stripe has the following rate limits: @@ -121,9 +124,10 @@ module.exports = class StripeAPI { * @param {boolean} options.active * @param {string} options.nickname * @param {string} options.currency - * @param {number} options.amount + * @param {number} [options.amount] + * @param {{enabled: boolean;maximum?: number;minimum?: number;preset?: number;}} [options.custom_unit_amount] * @param {'recurring'|'one-time'} options.type - * @param {Stripe.Price.Recurring.Interval|null} options.interval + * @param {Stripe.Price.Recurring.Interval|null} [options.interval] * * @returns {Promise} */ @@ -135,7 +139,9 @@ module.exports = class StripeAPI { unit_amount: options.amount, active: options.active, nickname: options.nickname, - recurring: options.type === 'recurring' ? { + // @ts-ignore + custom_unit_amount: options.custom_unit_amount, // missing in .d.ts definitions in the Stripe node version we use, but should be supported in Stripe API at this version (: + recurring: options.type === 'recurring' && options.interval ? { interval: options.interval } : undefined }); @@ -146,7 +152,7 @@ module.exports = class StripeAPI { /** * @param {string} id * @param {object} options - * @param {boolean} options.active + * @param {boolean} [options.active] * @param {string} [options.nickname] * * @returns {Promise} @@ -482,11 +488,60 @@ module.exports = class StripeAPI { stripeSessionOptions.customer_email = customerEmail; } + // @ts-ignore const session = await this._stripe.checkout.sessions.create(stripeSessionOptions); return session; } + /** + * @param {object} options + * @param {Object.} options.metadata + * @param {string} options.successUrl + * @param {string} options.cancelUrl + * @param {string} [options.customer] + * @param {string} [options.customerEmail] + * + * @returns {Promise} + */ + async createDonationCheckoutSession({priceId, successUrl, cancelUrl, metadata, customer, customerEmail}) { + await this._rateLimitBucket.throttle(); + + /** + * @type {Stripe.Checkout.SessionCreateParams} + */ + const stripeSessionOptions = { + mode: 'payment', + success_url: successUrl || this._config.checkoutSessionSuccessUrl, + cancel_url: cancelUrl || this._config.checkoutSessionCancelUrl, + automatic_tax: { + enabled: this._config.enableAutomaticTax + }, + metadata, + customer: customer ? customer.id : undefined, + customer_email: customer ? undefined : customerEmail, + submit_type: 'donate', + invoice_creation: { + enabled: true, + invoice_data: { + // Make sure we pass the data through to the invoice + metadata: { + ghost_donation: true, + ...metadata + } + } + }, + line_items: [{ + price: priceId, + quantity: 1 + }] + }; + + // @ts-ignore + const session = await this._stripe.checkout.sessions.create(stripeSessionOptions); + return session; + } + /** * @param {ICustomer} customer * @param {object} options diff --git a/ghost/stripe/lib/StripeService.js b/ghost/stripe/lib/StripeService.js index 040d4c4db0..0f0383e114 100644 --- a/ghost/stripe/lib/StripeService.js +++ b/ghost/stripe/lib/StripeService.js @@ -8,6 +8,7 @@ const {StripeLiveEnabledEvent, StripeLiveDisabledEvent} = require('./events'); module.exports = class StripeService { constructor({ membersService, + donationService, StripeWebhook, models }) { @@ -32,6 +33,9 @@ module.exports = class StripeService { get eventRepository() { return membersService.api.events; }, + get donationRepository() { + return donationService.repository; + }, sendSignupEmail(email){ return membersService.api.sendEmailWithMagicLink({ email, diff --git a/ghost/stripe/lib/WebhookController.js b/ghost/stripe/lib/WebhookController.js index 77047d05bb..188297562f 100644 --- a/ghost/stripe/lib/WebhookController.js +++ b/ghost/stripe/lib/WebhookController.js @@ -1,6 +1,7 @@ const _ = require('lodash'); const logging = require('@tryghost/logging'); const errors = require('@tryghost/errors'); +const {DonationPaymentEvent} = require('@tryghost/donations'); module.exports = class WebhookController { /** @@ -10,6 +11,7 @@ module.exports = class WebhookController { * @param {any} deps.eventRepository * @param {any} deps.memberRepository * @param {any} deps.productRepository + * @param {import('@tryghost/donations').DonationRepository} deps.donationRepository * @param {any} deps.sendSignupEmail */ constructor(deps) { @@ -102,10 +104,38 @@ module.exports = class WebhookController { } /** + * @param {import('stripe').Stripe.Invoice} invoice * @private */ async invoiceEvent(invoice) { if (!invoice.subscription) { + // Check if this is a one time payment, related to a donation + if (invoice.metadata.ghost_donation && invoice.paid) { + // Track a one time payment event + const amount = invoice.amount_paid; + + const member = await this.deps.memberRepository.get({ + customer_id: invoice.customer + }); + + const data = DonationPaymentEvent.create({ + name: member?.get('name') ?? invoice.customer_name, + email: member?.get('email') ?? invoice.customer_email, + memberId: member?.id ?? null, + amount, + currency: invoice.currency, + + // Attribution data + attributionId: invoice.metadata.attribution_id ?? null, + attributionUrl: invoice.metadata.attribution_url ?? null, + attributionType: invoice.metadata.attribution_type ?? null, + referrerSource: invoice.metadata.referrer_source ?? null, + referrerMedium: invoice.metadata.referrer_medium ?? null, + referrerUrl: invoice.metadata.referrer_url ?? null + }); + + await this.deps.donationRepository.save(data); + } return; } const subscription = await this.api.getSubscription(invoice.subscription, { diff --git a/package.json b/package.json index 76a3a5e734..e41df92700 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "author": "Ghost Foundation", "license": "MIT", "workspaces": [ - "apps/*", - "ghost/*" + "ghost/*", + "apps/*" ], "monorepo": { "public": false, diff --git a/yarn.lock b/yarn.lock index e4d2eb93d7..43c33d65ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10628,6 +10628,24 @@ c8@8.0.0: yargs "^16.2.0" yargs-parser "^20.2.9" +c8@8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/c8/-/c8-8.0.1.tgz#bafd60be680e66c5530ee69f621e45b1364af9fd" + integrity sha512-EINpopxZNH1mETuI0DzRA4MZpAUH+IFiRhnmFD3vFr3vdrgxqi3VfE3KL0AIL+zDq8rC9bZqwM/VDmmoe04y7w== + dependencies: + "@bcoe/v8-coverage" "^0.2.3" + "@istanbuljs/schema" "^0.1.3" + find-up "^5.0.0" + foreground-child "^2.0.0" + istanbul-lib-coverage "^3.2.0" + istanbul-lib-report "^3.0.1" + istanbul-reports "^3.1.6" + rimraf "^3.0.2" + test-exclude "^6.0.0" + v8-to-istanbul "^9.0.0" + yargs "^17.7.2" + yargs-parser "^21.1.1" + cac@^6.7.14: version "6.7.14" resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" @@ -18849,6 +18867,15 @@ istanbul-lib-report@^3.0.0: make-dir "^3.0.0" supports-color "^7.1.0" +istanbul-lib-report@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + istanbul-lib-source-maps@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" @@ -18866,6 +18893,14 @@ istanbul-reports@^3.0.2, istanbul-reports@^3.1.4, istanbul-reports@^3.1.5: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" +istanbul-reports@^3.1.6: + version "3.1.6" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.6.tgz#2544bcab4768154281a2f0870471902704ccaa1a" + integrity sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + istextorbinary@2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/istextorbinary/-/istextorbinary-2.1.0.tgz#dbed2a6f51be2f7475b68f89465811141b758874" @@ -20785,6 +20820,13 @@ make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0: dependencies: semver "^6.0.0" +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + make-error@^1.1.1, make-error@^1.3.6: version "1.3.6" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"