diff --git a/README.md b/README.md index 13093f31e..2b68fc7a9 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,10 @@ When using the Docker deployment, your database runs from a container. But if yo ## Versions History +### Version 4.7.0 (not released) + +- Add the ability for anonymous users to delete the boards they created under certain conditions ([#229](https://github.com/antoinejaussoin/retro-board/issues/229)). + ### Version 4.6.0 - Support OKTA for authentication diff --git a/backend/package.json b/backend/package.json index 7ee84418e..0bd71474a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -86,7 +86,7 @@ "socket.io-redis": "6.1.1", "stripe": "^8.168.0", "ts-jest": "^27.0.4", - "ts-node": "^10.2.0", + "ts-node": "^9.1.1", "typeorm": "^0.2.36", "uuid": "^8.3.2", "yargs": "^17.1.0" diff --git a/backend/src/auth/logins/anonymous-user.ts b/backend/src/auth/logins/anonymous-user.ts index b4c01b001..0ef557cff 100644 --- a/backend/src/auth/logins/anonymous-user.ts +++ b/backend/src/auth/logins/anonymous-user.ts @@ -1,19 +1,36 @@ import { UserEntity } from '../../db/entities'; import { v4 } from 'uuid'; -import { getUserByUsername, getOrSaveUser } from '../../db/actions/users'; +import { + getUserByUsername, + getOrSaveUser, + updateUserPassword, +} from '../../db/actions/users'; +import { hashPassword } from '../../utils'; +import { compare } from 'bcryptjs'; export default async function loginAnonymous( - username: string -): Promise { + username: string, + password: string +): Promise { const actualUsername = username.split('^')[0]; const existingUser = await getUserByUsername(username); - if (existingUser) { - return existingUser; + if (!existingUser) { + const hashedPassword = await hashPassword(password); + const user = new UserEntity(v4(), actualUsername, hashedPassword); + user.username = username; + user.language = 'en'; + + const dbUser = await getOrSaveUser(user); + return dbUser; + } + + if (!existingUser.password) { + const hashedPassword = await hashPassword(password); + const dbUser = await updateUserPassword(existingUser.id, hashedPassword); + return dbUser; } - const user = new UserEntity(v4(), actualUsername); - user.username = username; - user.language = 'en'; - const dbUser = await getOrSaveUser(user); - return dbUser; + const isPasswordCorrect = await compare(password, existingUser.password); + + return isPasswordCorrect ? existingUser : null; } diff --git a/backend/src/auth/passport.ts b/backend/src/auth/passport.ts index dbd187cb1..4a75e6479 100644 --- a/backend/src/auth/passport.ts +++ b/backend/src/auth/passport.ts @@ -18,8 +18,8 @@ import { import { v4 } from 'uuid'; import { AccountType } from '@retrospected/common'; import chalk from 'chalk'; -import loginAnonymous from './logins/anonymous-user'; import loginUser from './logins/password-user'; +import loginAnonymous from './logins/anonymous-user'; import UserEntity from '../db/entities/User'; import { BaseProfile, @@ -195,12 +195,20 @@ export default () => { options?: IVerifyOptions ) => void ) => { - if (password && password !== '<<<<>>>>') { + if ( + username.startsWith('ANONUSER__') && + username.endsWith('__ANONUSER') + ) { + // Anonymouns login + const actualUsername = username + .replace('ANONUSER__', '') + .replace('__ANONUSER', ''); + const user = await loginAnonymous(actualUsername, password); + done(!user ? 'Anonymous account not valid' : null, user?.id); + } else { + // Regular account login const user = await loginUser(username, password); done(!user ? 'User cannot log in' : null, user?.id); - } else { - const user = await loginAnonymous(username); - done(null, user.id); } } ) diff --git a/backend/src/db/actions/sessions.ts b/backend/src/db/actions/sessions.ts index 310cce591..9801a0ab9 100644 --- a/backend/src/db/actions/sessions.ts +++ b/backend/src/db/actions/sessions.ts @@ -30,7 +30,8 @@ import { import { orderBy } from 'lodash'; import { transaction } from './transaction'; import { EntityManager } from 'typeorm'; -import { isUserPro } from './users'; +import { getUserViewInner, isUserPro } from './users'; +import { ALL_FIELDS } from '../entities/User'; export async function createSession( author: UserEntity, @@ -186,14 +187,16 @@ export async function deleteSessions( return await transaction(async (manager) => { const sessionRepository = manager.getCustomRepository(SessionRepository); const session = await sessionRepository.findOne(sessionId); + const user = await getUserViewInner(manager, userId); + if (!user) { + console.info('User not found', userId); + return false; + } if (!session) { console.info('Session not found', sessionId); return false; } - if ( - session.createdBy.id !== userId || - session.createdBy.accountType === 'anonymous' - ) { + if (session.createdBy.id !== userId || !user.canDeleteSession) { console.error( 'The user is not the one who created the session, or is anonymous' ); @@ -254,6 +257,7 @@ export async function previousSessions( const userRepository = manager.getCustomRepository(UserRepository); const loadedUser = await userRepository.findOne(userId, { relations: ['sessions', 'sessions.posts', 'sessions.visitors'], + select: ALL_FIELDS, }); if (loadedUser && loadedUser.sessions) { return orderBy(loadedUser.sessions, (s) => s.updated, 'desc').map( @@ -276,7 +280,7 @@ export async function previousSessions( participants: getParticipants(session.visitors), canBeDeleted: userId === session.createdBy.id && - session.createdBy.accountType !== 'anonymous', + (loadedUser.accountType !== 'anonymous' || !!loadedUser.password), } as SessionMetadata) ); } diff --git a/backend/src/db/actions/users.ts b/backend/src/db/actions/users.ts index bddfcd451..c2c446f05 100644 --- a/backend/src/db/actions/users.ts +++ b/backend/src/db/actions/users.ts @@ -37,7 +37,7 @@ export async function getUserView(id: string): Promise { }); } -async function getUserViewInner( +export async function getUserViewInner( manager: EntityManager, id: string ): Promise { @@ -96,6 +96,25 @@ export async function getOrSaveUser(user: UserEntity): Promise { }); } +export async function updateUserPassword( + id: string, + password: string +): Promise { + return await transaction(async (manager) => { + const userRepository = manager.getCustomRepository(UserRepository); + const existingUser = await userRepository.findOne({ + where: { id }, + }); + if (existingUser) { + return await userRepository.save({ + ...existingUser, + password, + }); + } + return null; + }); +} + export function isUserPro(user: FullUser) { // TODO: deduplicate from same logic in Frontend frontend/src/auth/useIsPro.ts if (isSelfHostedAndLicenced()) { diff --git a/backend/src/db/entities/UserView.ts b/backend/src/db/entities/UserView.ts index c02e59422..a7b92676f 100644 --- a/backend/src/db/entities/UserView.ts +++ b/backend/src/db/entities/UserView.ts @@ -13,6 +13,7 @@ select u.photo, u.language, u.email, + case when u."accountType" = 'anonymous' and u.password is null then false else true end as "canDeleteSession", u.trial, s.id as "ownSubscriptionsId", s.plan as "ownPlan", @@ -39,6 +40,8 @@ export default class UserView { @ViewColumn() public email: string | null; @ViewColumn() + public canDeleteSession: boolean; + @ViewColumn() public stripeId: string | null; @ViewColumn() public photo: string | null; @@ -72,6 +75,7 @@ export default class UserView { this.subscriptionsId = null; this.pro = false; this.email = null; + this.canDeleteSession = false; this.currency = null; this.ownPlan = null; this.ownSubscriptionsId = null; @@ -86,6 +90,7 @@ export default class UserView { name: this.name, photo: this.photo, email: this.email, + canDeleteSession: this.canDeleteSession, pro: this.pro, subscriptionsId: this.subscriptionsId, accountType: this.accountType, diff --git a/backend/src/db/migrations/1628773645790-CanDeleteSessions.ts b/backend/src/db/migrations/1628773645790-CanDeleteSessions.ts new file mode 100644 index 000000000..50f057f4e --- /dev/null +++ b/backend/src/db/migrations/1628773645790-CanDeleteSessions.ts @@ -0,0 +1,65 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class CanDeleteSessions1628773645790 implements MigrationInterface { + name = 'CanDeleteSessions1628773645790' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "schema" = $2 AND "name" = $3`, ["VIEW","public","user_view"]); + await queryRunner.query(`DROP VIEW "public"."user_view"`); + await queryRunner.query(`CREATE VIEW "user_view" AS +select + u.id, + u.name, + u."accountType", + u.username, + u.currency, + u."stripeId", + u.photo, + u.language, + u.email, + case when u."accountType" = 'anonymous' and u.password is null then false else true end as "canDeleteSession", + u.trial, + s.id as "ownSubscriptionsId", + s.plan as "ownPlan", + coalesce(s.id, s2.id, s3.id) as "subscriptionsId", + coalesce(s.active, s2.active, s3.active, false) as "pro", + coalesce(s.plan, s2.plan, s3.plan) as "plan", + coalesce(s.domain, s2.domain, s3.domain) as "domain" +from users u + +left join subscriptions s on s."ownerId" = u.id and s.active is true +left join subscriptions s2 on lower(u.email) = any(lower(s2.members::text)::text[]) and s2.active is true +left join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true + `); + await queryRunner.query(`INSERT INTO "typeorm_metadata"("type", "schema", "name", "value") VALUES ($1, $2, $3, $4)`, ["VIEW","public","user_view","select \n u.id,\n u.name,\n u.\"accountType\",\n u.username,\n u.currency,\n u.\"stripeId\",\n u.photo,\n u.language,\n u.email,\n case when u.\"accountType\" = 'anonymous' and u.password is null then false else true end as \"canDeleteSession\",\n u.trial,\n s.id as \"ownSubscriptionsId\",\n s.plan as \"ownPlan\",\n coalesce(s.id, s2.id, s3.id) as \"subscriptionsId\",\n coalesce(s.active, s2.active, s3.active, false) as \"pro\",\n coalesce(s.plan, s2.plan, s3.plan) as \"plan\",\n coalesce(s.domain, s2.domain, s3.domain) as \"domain\"\nfrom users u \n\nleft join subscriptions s on s.\"ownerId\" = u.id and s.active is true\nleft join subscriptions s2 on lower(u.email) = any(lower(s2.members::text)::text[]) and s2.active is true\nleft join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true"]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "schema" = $2 AND "name" = $3`, ["VIEW","public","user_view"]); + await queryRunner.query(`DROP VIEW "user_view"`); + await queryRunner.query(`CREATE VIEW "public"."user_view" AS select + u.id, + u.name, + u."accountType", + u.username, + u.currency, + u."stripeId", + u.photo, + u.language, + u.email, + u.trial, + s.id as "ownSubscriptionsId", + s.plan as "ownPlan", + coalesce(s.id, s2.id, s3.id) as "subscriptionsId", + coalesce(s.active, s2.active, s3.active, false) as "pro", + coalesce(s.plan, s2.plan, s3.plan) as "plan", + coalesce(s.domain, s2.domain, s3.domain) as "domain" +from users u + +left join subscriptions s on s."ownerId" = u.id and s.active is true +left join subscriptions s2 on lower(u.email) = any(lower(s2.members::text)::text[]) and s2.active is true +left join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true`); + await queryRunner.query(`INSERT INTO "typeorm_metadata"("type", "schema", "name", "value") VALUES ($1, $2, $3, $4)`, ["VIEW","public","user_view","select \n u.id,\n u.name,\n u.\"accountType\",\n u.username,\n u.currency,\n u.\"stripeId\",\n u.photo,\n u.language,\n u.email,\n u.trial,\n s.id as \"ownSubscriptionsId\",\n s.plan as \"ownPlan\",\n coalesce(s.id, s2.id, s3.id) as \"subscriptionsId\",\n coalesce(s.active, s2.active, s3.active, false) as \"pro\",\n coalesce(s.plan, s2.plan, s3.plan) as \"plan\",\n coalesce(s.domain, s2.domain, s3.domain) as \"domain\"\nfrom users u \n\nleft join subscriptions s on s.\"ownerId\" = u.id and s.active is true\nleft join subscriptions s2 on lower(u.email) = any(lower(s2.members::text)::text[]) and s2.active is true\nleft join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true"]); + } + +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 750f63696..deffa52a8 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -293,7 +293,7 @@ db().then(() => { app.delete('/api/session/:sessionId', heavyLoadLimiter, async (req, res) => { const sessionId = req.params.sessionId; const user = await getUserFromRequest(req); - if (user && user.accountType !== 'anonymous') { + if (user) { const success = await deleteSessions(user.id, sessionId); cache.invalidate(user.id); if (success) { diff --git a/common/src/types.ts b/common/src/types.ts index 46696f042..c0bd5daf9 100644 --- a/common/src/types.ts +++ b/common/src/types.ts @@ -117,6 +117,7 @@ export interface FullUser extends User { accountType: AccountType; language: string; email: string | null; + canDeleteSession: boolean; stripeId: string | null; pro: boolean; subscriptionsId: string | null; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index bb4205ed1..d1dc8e9e6 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -66,10 +66,12 @@ export async function anonymousLogin( username: string ): Promise { const anonymousUsername = getAnonymousUsername(username); - const success = await fetchPost('/api/auth/anonymous/login', { - username: anonymousUsername, - password: '<<<<>>>>', + const password = getAnonUserPassword(anonymousUsername); + const success = await fetchPost('/api/auth/login', { + username: `ANONUSER__${anonymousUsername}__ANONUSER`, + password, }); + if (success) { return me(); } @@ -180,6 +182,16 @@ function getAnonymousUsername(username: string): string { return storedUsername; } +function getAnonUserPassword(username: string) { + const key = `anonymous-password-${username}`; + let password = getItem(key); + if (!password) { + password = v4(); + setItem(key, password); + } + return password; +} + export async function updateLanguage( language: string ): Promise { diff --git a/frontend/src/auth/modal/AnonAuth.tsx b/frontend/src/auth/modal/AnonAuth.tsx index 88da138ba..4fe40482f 100644 --- a/frontend/src/auth/modal/AnonAuth.tsx +++ b/frontend/src/auth/modal/AnonAuth.tsx @@ -15,13 +15,18 @@ interface AnonAuthProps { const AnonAuth = ({ onClose, onUser }: AnonAuthProps) => { const { AnonymousLogin: loginTranslations } = useTranslations(); const language = useLanguage(); - const [username, setUsername] = useState(''); + const [error, setError] = useState(''); + const handleAnonLogin = useCallback(() => { async function login() { const trimmedUsername = username.trim(); if (trimmedUsername.length) { - await anonymousLogin(trimmedUsername); + const user = await anonymousLogin(trimmedUsername); + if (!user) { + setError('Your anonymous account is not valid.'); + return; + } const updatedUser = await updateLanguage(language.value); onUser(updatedUser); if (onClose) { @@ -53,6 +58,11 @@ const AnonAuth = ({ onClose, onUser }: AnonAuthProps) => { {loginTranslations.anonymousAuthDescription} + {!!error ? ( + + {error} + + ) : null} { ownPlan: null, ownSubscriptionsId: null, trial: null, + canDeleteSession: false, }); useEffect(() => { receiveBoard(initialSession); @@ -76,6 +77,7 @@ const Inner: React.FC = ({ children }) => { ownPlan: null, ownSubscriptionsId: null, trial: null, + canDeleteSession: false, }); }, [receiveBoard]); return ( diff --git a/frontend/src/views/home/game-item/PreviousGameItem.tsx b/frontend/src/views/home/game-item/PreviousGameItem.tsx index 51889f73e..e531961ac 100644 --- a/frontend/src/views/home/game-item/PreviousGameItem.tsx +++ b/frontend/src/views/home/game-item/PreviousGameItem.tsx @@ -74,7 +74,7 @@ const PreviousGameItem = ({ {formatDistanceToNow( - Date.parse((session.created as unknown) as string), + Date.parse(session.created as unknown as string), true )}   diff --git a/yarn.lock b/yarn.lock index 89b8568b5..4069bbcea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1209,18 +1209,6 @@ exec-sh "^0.3.2" minimist "^1.2.0" -"@cspotcode/source-map-consumer@0.8.0": - version "0.8.0" - resolved "https://registry.yarnpkg.com/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz#33bf4b7b39c178821606f669bbc447a6a629786b" - integrity sha512-41qniHzTU8yAGbCp04ohlmSrZf8bkf/iJsl3V0dRGsQN/5GFfx+LbCSsCpp2gqrqjTVg/K6O8ycoV35JIwAzAg== - -"@cspotcode/source-map-support@0.6.1": - version "0.6.1" - resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.6.1.tgz#118511f316e2e87ee4294761868e254d3da47960" - integrity sha512-DX3Z+T5dt1ockmPdobJS/FAsQPW4V4SrWEhD2iYQT2Cb2tQsiMnYxrcUH9By/Z3B+v0S5LMBkQtV/XOBbpLEOg== - dependencies: - "@cspotcode/source-map-consumer" "0.8.0" - "@csstools/convert-colors@^1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@csstools/convert-colors/-/convert-colors-1.4.0.tgz#ad495dc41b12e75d588c6db8b9834f08fa131eb7" @@ -2167,26 +2155,6 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== -"@tsconfig/node10@^1.0.7": - version "1.0.8" - resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9" - integrity sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg== - -"@tsconfig/node12@^1.0.7": - version "1.0.9" - resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.9.tgz#62c1f6dee2ebd9aead80dc3afa56810e58e1a04c" - integrity sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw== - -"@tsconfig/node14@^1.0.0": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.1.tgz#95f2d167ffb9b8d2068b0b235302fafd4df711f2" - integrity sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg== - -"@tsconfig/node16@^1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e" - integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== - "@types/aria-query@^4.2.0": version "4.2.2" resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.2.tgz#ed4e0ad92306a704f9fb132a0cfcf77486dbe2bc" @@ -3133,11 +3101,6 @@ acorn-walk@^7.1.1: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== -acorn-walk@^8.1.1: - version "8.1.1" - resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.1.1.tgz#3ddab7f84e4a7e2313f6c414c5b7dac85f4e3ebc" - integrity sha512-FbJdceMlPHEAWJOILDk1fXD8lnTlEIWFkqtfk+MvmL5q/qlHfN7GEHcsFZWt/Tea9jRNPWUZG4G976nqAAmU9w== - acorn@^6.4.1: version "6.4.2" resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6" @@ -3148,7 +3111,7 @@ acorn@^7.1.0, acorn@^7.1.1, acorn@^7.4.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.2.4, acorn@^8.4.1: +acorn@^8.2.4: version "8.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.4.1.tgz#56c36251fc7cabc7096adc18f05afe814321a28c" integrity sha512-asabaBSkEKosYKMITunzX177CXxQ4Q8BSSzMTKD+FefUhipQC70gfW5SiUDhYQ3vk8G+81HqQk7Fv9OXwwn9KA== @@ -13728,7 +13691,7 @@ source-map-resolve@^0.6.0: atob "^2.1.2" decode-uri-component "^0.2.0" -source-map-support@^0.5.6, source-map-support@~0.5.12, source-map-support@~0.5.19: +source-map-support@^0.5.17, source-map-support@^0.5.6, source-map-support@~0.5.12, source-map-support@~0.5.19: version "0.5.19" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw== @@ -14566,22 +14529,16 @@ ts-jest@^27.0.4: semver "7.x" yargs-parser "20.x" -ts-node@^10.2.0: - version "10.2.0" - resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.2.0.tgz#f1e88249a00e26aa95e9a93c50f70241a8a1c4bb" - integrity sha512-FstYHtQz6isj8rBtYMN4bZdnXN1vq4HCbqn9vdNQcInRqtB86PePJQIxE6es0PhxKWhj2PHuwbG40H+bxkZPmg== - dependencies: - "@cspotcode/source-map-support" "0.6.1" - "@tsconfig/node10" "^1.0.7" - "@tsconfig/node12" "^1.0.7" - "@tsconfig/node14" "^1.0.0" - "@tsconfig/node16" "^1.0.2" - acorn "^8.4.1" - acorn-walk "^8.1.1" +ts-node@^9.1.1: + version "9.1.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-9.1.1.tgz#51a9a450a3e959401bda5f004a72d54b936d376d" + integrity sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg== + dependencies: arg "^4.1.0" create-require "^1.1.0" diff "^4.0.1" make-error "^1.1.1" + source-map-support "^0.5.17" yn "3.1.1" ts-pnp@1.2.0, ts-pnp@^1.1.6: