1
0
Fork 0
mirror of synced 2024-07-07 07:15:43 +12:00

Merge pull request #12346 from Budibase/bug/budi-7008-i-was-able-to-send-two-invitations-to-the-same-user-email-2

Prevent sending invites to users who have already been invited.
This commit is contained in:
Sam Rose 2023-11-17 14:18:45 +00:00 committed by GitHub
commit 7412fd5a5c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 301 additions and 248 deletions

View file

@ -50,6 +50,7 @@ export * from "./constants"
// expose package init function
import * as db from "./db"
export const init = (opts: any = {}) => {
db.init(opts.db)
}

View file

@ -4,3 +4,5 @@ export { default as Client } from "./redis"
export * as utils from "./utils"
export * as clients from "./init"
export * as locks from "./redlockImpl"
export * as invite from "./invite"
export * as passwordReset from "./passwordReset"

View file

@ -0,0 +1,91 @@
import { utils, tenancy, redis } from "../"
import env from "../environment"
const TTL_SECONDS = 60 * 60 * 24 * 7
interface Invite {
email: string
info: any
}
interface InviteWithCode extends Invite {
code: string
}
let client: redis.Client
export async function init() {
if (!client) {
client = new redis.Client(redis.utils.Databases.INVITATIONS)
}
return client
}
export async function shutdown() {
if (client) await client.finish()
}
/**
* Given an invite code and invite body, allow the update an existing/valid invite in redis
* @param inviteCode The invite code for an invite in redis
* @param value The body of the updated user invitation
*/
export async function updateCode(code: string, value: Invite) {
await client.store(code, value, TTL_SECONDS)
}
/**
* Generates an invitation code and writes it to redis - which can later be checked for user creation.
* @param email the email address which the code is being sent to (for use later).
* @param info Information to be carried along with the invitation.
* @return returns the code that was stored to redis.
*/
export async function createCode(email: string, info: any): Promise<string> {
const code = utils.newid()
await client.store(code, { email, info }, TTL_SECONDS)
return code
}
/**
* Checks that the provided invite code is valid - will return the email address of user that was invited.
* @param inviteCode the invite code that was provided as part of the link.
* @return If the code is valid then an email address will be returned.
*/
export async function getCode(code: string): Promise<Invite> {
const value = (await client.get(code)) as Invite | undefined
if (!value) {
throw "Invitation is not valid or has expired, please request a new one."
}
return value
}
export async function deleteCode(code: string) {
await client.delete(code)
}
/**
Get all currently available user invitations for the current tenant.
**/
export async function getInviteCodes(): Promise<InviteWithCode[]> {
const invites: { key: string; value: Invite }[] = await client.scan()
const results: InviteWithCode[] = invites.map(invite => {
return {
...invite.value,
code: invite.key,
}
})
if (!env.MULTI_TENANCY) {
return results
}
const tenantId = tenancy.getTenantId()
return results.filter(invite => tenantId === invite.info.tenantId)
}
export async function getExistingInvites(
emails: string[]
): Promise<InviteWithCode[]> {
return (await getInviteCodes()).filter(invite =>
emails.includes(invite.email)
)
}

View file

@ -0,0 +1,47 @@
import { redis, utils } from "../"
const TTL_SECONDS = 60 * 60
interface PasswordReset {
userId: string
info: any
}
let client: redis.Client
export async function init() {
if (!client) {
client = new redis.Client(redis.utils.Databases.PW_RESETS)
}
return client
}
export async function shutdown() {
if (client) await client.finish()
}
/**
* Given a user ID this will store a code (that is returned) for an hour in redis.
* The user can then return this code for resetting their password (through their reset link).
* @param userId the ID of the user which is to be reset.
* @param info Info about the user/the reset process.
* @return returns the code that was stored to redis.
*/
export async function createCode(userId: string, info: any): Promise<string> {
const code = utils.newid()
await client.store(code, { userId, info }, TTL_SECONDS)
return code
}
/**
* Given a reset code this will lookup to redis, check if the code is valid.
* @param code The code provided via the email link.
* @return returns the user ID if it is found
*/
export async function getCode(code: string): Promise<PasswordReset> {
const value = (await client.get(code)) as PasswordReset | undefined
if (!value) {
throw "Provided information is not valid, cannot reset password - please try again."
}
return value
}

View file

@ -303,7 +303,7 @@ export class UserDB {
static async bulkCreate(
newUsersRequested: User[],
groups: string[]
groups?: string[]
): Promise<BulkUserCreated> {
const tenantId = getTenantId()
@ -328,7 +328,7 @@ export class UserDB {
})
continue
}
newUser.userGroups = groups
newUser.userGroups = groups || []
newUsers.push(newUser)
if (isCreator(newUser)) {
newCreators.push(newUser)

View file

@ -6,6 +6,7 @@ import {
} from "@budibase/types"
import * as dbUtils from "../db"
import { ViewName } from "../constants"
import { getExistingInvites } from "../redis/invite"
/**
* Apply a system-wide search on emails:
@ -26,6 +27,9 @@ export async function searchExistingEmails(emails: string[]) {
const existingAccounts = await getExistingAccounts(emails)
matchedEmails.push(...existingAccounts.map(account => account.email))
const invitedEmails = await getExistingInvites(emails)
matchedEmails.push(...invitedEmails.map(invite => invite.email))
return [...new Set(matchedEmails.map(email => email.toLowerCase()))]
}

View file

@ -12,7 +12,7 @@ import { generator } from "./generator"
import { tenant } from "."
export const newEmail = () => {
return `${uuid()}@test.com`
return `${uuid()}@example.com`
}
export const user = (userProps?: Partial<Omit<User, "userId">>): User => {

View file

@ -10,6 +10,7 @@ export interface SaveUserResponse {
export interface UserDetails {
_id: string
email: string
password?: string
}
export interface BulkUserRequest {
@ -49,6 +50,7 @@ export type InviteUsersRequest = InviteUserRequest[]
export interface InviteUsersResponse {
successful: { email: string }[]
unsuccessful: { email: string; reason: string }[]
created?: boolean
}
export interface SearchUsersRequest {

View file

@ -1,8 +1,4 @@
import {
checkInviteCode,
getInviteCodes,
updateInviteCode,
} from "../../../utilities/redis"
import { redis } from "@budibase/backend-core"
import * as userSdk from "../../../sdk/users"
import env from "../../../environment"
import {
@ -16,6 +12,7 @@ import {
Ctx,
InviteUserRequest,
InviteUsersRequest,
InviteUsersResponse,
MigrationType,
SaveUserResponse,
SearchUsersRequest,
@ -249,59 +246,35 @@ export const tenantUserLookup = async (ctx: any) => {
/*
Encapsulate the app user onboarding flows here.
*/
export const onboardUsers = async (ctx: Ctx<InviteUsersRequest>) => {
const request = ctx.request.body
const isBulkCreate = "create" in request
const emailConfigured = await isEmailConfigured()
let onboardingResponse
if (isBulkCreate) {
// @ts-ignore
const { users, groups, roles } = request.create
const assignUsers = users.map((user: User) => (user.roles = roles))
onboardingResponse = await userSdk.db.bulkCreate(assignUsers, groups)
ctx.body = onboardingResponse
} else if (emailConfigured) {
onboardingResponse = await inviteMultiple(ctx)
} else if (!emailConfigured) {
const inviteRequest = ctx.request.body
let createdPasswords: any = {}
const users: User[] = inviteRequest.map(invite => {
let password = Math.random().toString(36).substring(2, 22)
// Temp password to be passed to the user.
createdPasswords[invite.email] = password
return {
email: invite.email,
password,
forceResetPassword: true,
roles: invite.userInfo.apps,
admin: invite.userInfo.admin,
builder: invite.userInfo.builder,
tenantId: tenancy.getTenantId(),
}
})
let bulkCreateReponse = await userSdk.db.bulkCreate(users, [])
// Apply temporary credentials
ctx.body = {
...bulkCreateReponse,
successful: bulkCreateReponse?.successful.map(user => {
return {
...user,
password: createdPasswords[user.email],
}
}),
created: true,
}
} else {
ctx.throw(400, "User onboarding failed")
export const onboardUsers = async (
ctx: Ctx<InviteUsersRequest, InviteUsersResponse>
) => {
if (await isEmailConfigured()) {
await inviteMultiple(ctx)
return
}
let createdPasswords: Record<string, string> = {}
const users: User[] = ctx.request.body.map(invite => {
let password = Math.random().toString(36).substring(2, 22)
createdPasswords[invite.email] = password
return {
email: invite.email,
password,
forceResetPassword: true,
roles: invite.userInfo.apps,
admin: invite.userInfo.admin,
builder: invite.userInfo.builder,
tenantId: tenancy.getTenantId(),
}
})
let resp = await userSdk.db.bulkCreate(users)
for (const user of resp.successful) {
user.password = createdPasswords[user.email]
}
ctx.body = { ...resp, created: true }
}
export const invite = async (ctx: Ctx<InviteUserRequest>) => {
@ -328,18 +301,18 @@ export const invite = async (ctx: Ctx<InviteUserRequest>) => {
}
export const inviteMultiple = async (ctx: Ctx<InviteUsersRequest>) => {
const request = ctx.request.body
ctx.body = await userSdk.invite(request)
ctx.body = await userSdk.invite(ctx.request.body)
}
export const checkInvite = async (ctx: any) => {
const { code } = ctx.params
let invite
try {
invite = await checkInviteCode(code, false)
invite = await redis.invite.getCode(code)
} catch (e) {
console.warn("Error getting invite from code", e)
ctx.throw(400, "There was a problem with the invite")
return
}
ctx.body = {
email: invite.email,
@ -347,14 +320,12 @@ export const checkInvite = async (ctx: any) => {
}
export const getUserInvites = async (ctx: any) => {
let invites
try {
// Restricted to the currently authenticated tenant
invites = await getInviteCodes()
ctx.body = await redis.invite.getInviteCodes()
} catch (e) {
ctx.throw(400, "There was a problem fetching invites")
}
ctx.body = invites
}
export const updateInvite = async (ctx: any) => {
@ -365,12 +336,10 @@ export const updateInvite = async (ctx: any) => {
let invite
try {
invite = await checkInviteCode(code, false)
if (!invite) {
throw new Error("The invite could not be retrieved")
}
invite = await redis.invite.getCode(code)
} catch (e) {
ctx.throw(400, "There was a problem with the invite")
return
}
let updated = {
@ -395,7 +364,7 @@ export const updateInvite = async (ctx: any) => {
}
}
await updateInviteCode(code, updated)
await redis.invite.updateCode(code, updated)
ctx.body = { ...invite }
}
@ -405,7 +374,8 @@ export const inviteAccept = async (
const { inviteCode, password, firstName, lastName } = ctx.request.body
try {
// info is an extension of the user object that was stored by global
const { email, info }: any = await checkInviteCode(inviteCode)
const { email, info }: any = await redis.invite.getCode(inviteCode)
await redis.invite.deleteCode(inviteCode)
const user = await tenancy.doInTenant(info.tenantId, async () => {
let request: any = {
firstName,

View file

@ -1,11 +1,12 @@
import { InviteUsersResponse, User } from "@budibase/types"
jest.mock("nodemailer")
import { TestConfiguration, mocks, structures } from "../../../../tests"
const sendMailMock = mocks.email.mock()
import { events, tenancy, accounts as _accounts } from "@budibase/backend-core"
import * as userSdk from "../../../../sdk/users"
jest.mock("nodemailer")
const sendMailMock = mocks.email.mock()
const accounts = jest.mocked(_accounts)
describe("/api/global/users", () => {
@ -54,6 +55,24 @@ describe("/api/global/users", () => {
expect(events.user.invited).toBeCalledTimes(0)
})
it("should not invite the same user twice", async () => {
const email = structures.users.newEmail()
await config.api.users.sendUserInvite(sendMailMock, email)
jest.clearAllMocks()
const { code, res } = await config.api.users.sendUserInvite(
sendMailMock,
email,
400
)
expect(res.body.message).toBe(`Unavailable`)
expect(sendMailMock).toHaveBeenCalledTimes(0)
expect(code).toBeUndefined()
expect(events.user.invited).toBeCalledTimes(0)
})
it("should be able to create new user from invite", async () => {
const email = structures.users.newEmail()
const { code } = await config.api.users.sendUserInvite(
@ -101,6 +120,23 @@ describe("/api/global/users", () => {
expect(sendMailMock).toHaveBeenCalledTimes(0)
expect(events.user.invited).toBeCalledTimes(0)
})
it("should not be able to generate an invitation for user that has already been invited", async () => {
const email = structures.users.newEmail()
await config.api.users.sendUserInvite(sendMailMock, email)
jest.clearAllMocks()
const request = [{ email: email, userInfo: {} }]
const res = await config.api.users.sendMultiUserInvite(request)
const body = res.body as InviteUsersResponse
expect(body.successful.length).toBe(0)
expect(body.unsuccessful.length).toBe(1)
expect(body.unsuccessful[0].reason).toBe("Unavailable")
expect(sendMailMock).toHaveBeenCalledTimes(0)
expect(events.user.invited).toBeCalledTimes(0)
})
})
describe("POST /api/global/users/bulk", () => {
@ -633,4 +669,25 @@ describe("/api/global/users", () => {
expect(response.body.message).toBe("Unable to delete self.")
})
})
describe("POST /api/global/users/onboard", () => {
it("should successfully onboard a user", async () => {
const response = await config.api.users.onboardUser([
{ email: structures.users.newEmail(), userInfo: {} },
])
expect(response.successful.length).toBe(1)
expect(response.unsuccessful.length).toBe(0)
})
it("should not onboard a user who has been invited", async () => {
const email = structures.users.newEmail()
await config.api.users.sendUserInvite(sendMailMock, email)
const response = await config.api.users.onboardUser([
{ email, userInfo: {} },
])
expect(response.successful.length).toBe(0)
expect(response.unsuccessful.length).toBe(1)
})
})
})

View file

@ -16,13 +16,13 @@ import {
queue,
env as coreEnv,
timers,
redis,
} from "@budibase/backend-core"
db.init()
import Koa from "koa"
import koaBody from "koa-body"
import http from "http"
import api from "./api"
import * as redis from "./utilities/redis"
const koaSession = require("koa-session")
import { userAgent } from "koa-useragent"
@ -72,7 +72,8 @@ server.on("close", async () => {
shuttingDown = true
console.log("Server Closed")
timers.cleanup()
await redis.shutdown()
await redis.invite.shutdown()
await redis.passwordReset.shutdown()
await events.shutdown()
await queue.shutdown()
if (!env.isTest()) {
@ -88,7 +89,8 @@ const shutdown = () => {
export default server.listen(parseInt(env.PORT || "4002"), async () => {
console.log(`Worker running on ${JSON.stringify(server.address())}`)
await initPro()
await redis.init()
await redis.invite.init()
await redis.passwordReset.init()
// configure events to use the pro audit log write
// can't integrate directly into backend-core due to cyclic issues
await events.processors.init(proSdk.auditLogs.write)

View file

@ -6,12 +6,12 @@ import {
sessions,
tenancy,
utils as coreUtils,
redis,
} from "@budibase/backend-core"
import { PlatformLogoutOpts, User } from "@budibase/types"
import jwt from "jsonwebtoken"
import * as userSdk from "../users"
import * as emails from "../../utilities/email"
import * as redis from "../../utilities/redis"
import { EmailTemplatePurpose } from "../../constants"
// LOGIN / LOGOUT
@ -73,7 +73,7 @@ export const reset = async (email: string) => {
* Perform the user password update if the provided reset code is valid.
*/
export const resetUpdate = async (resetCode: string, password: string) => {
const { userId } = await redis.checkResetPasswordCode(resetCode)
const { userId } = await redis.passwordReset.getCode(resetCode)
let user = await userSdk.db.getUser(userId)
user.password = password

View file

@ -1,5 +1,9 @@
import { events, tenancy, users as usersCore } from "@budibase/backend-core"
import { InviteUsersRequest, InviteUsersResponse } from "@budibase/types"
import {
InviteUserRequest,
InviteUsersRequest,
InviteUsersResponse,
} from "@budibase/types"
import { sendEmail } from "../../utilities/email"
import { EmailTemplatePurpose } from "../../constants"
@ -14,11 +18,13 @@ export async function invite(
const matchedEmails = await usersCore.searchExistingEmails(
users.map(u => u.email)
)
const newUsers = []
const newUsers: InviteUserRequest[] = []
// separate duplicates from new users
for (let user of users) {
if (matchedEmails.includes(user.email)) {
// This "Unavailable" is load bearing. The tests and frontend both check for it
// specifically
response.unsuccessful.push({ email: user.email, reason: "Unavailable" })
} else {
newUsers.push(user)

View file

@ -5,6 +5,7 @@ import {
User,
CreateAdminUserRequest,
SearchQuery,
InviteUsersResponse,
} from "@budibase/types"
import structures from "../structures"
import { generator } from "@budibase/backend-core/tests"
@ -176,4 +177,24 @@ export class UserAPI extends TestAPI {
.expect("Content-Type", /json/)
.expect(200)
}
onboardUser = async (
req: InviteUsersRequest
): Promise<InviteUsersResponse> => {
const resp = await this.request
.post(`/api/global/users/onboard`)
.send(req)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
if (resp.status !== 200) {
throw new Error(
`request failed with status ${resp.status} and body ${JSON.stringify(
resp.body
)}`
)
}
return resp.body as InviteUsersResponse
}
}

View file

@ -3,7 +3,7 @@ import { EmailTemplatePurpose, TemplateType } from "../constants"
import { getTemplateByPurpose, EmailTemplates } from "../constants/templates"
import { getSettingsTemplateContext } from "./templates"
import { processString } from "@budibase/string-templates"
import { getResetPasswordCode, getInviteCode } from "./redis"
import { redis } from "@budibase/backend-core"
import { User, SendEmailOpts, SMTPInnerConfig } from "@budibase/types"
import { configs } from "@budibase/backend-core"
import ical from "ical-generator"
@ -61,9 +61,9 @@ async function getLinkCode(
) {
switch (purpose) {
case EmailTemplatePurpose.PASSWORD_RECOVERY:
return getResetPasswordCode(user._id!, info)
return redis.passwordReset.createCode(user._id!, info)
case EmailTemplatePurpose.INVITATION:
return getInviteCode(email, info)
return redis.invite.createCode(email, info)
default:
return null
}

View file

@ -1,150 +0,0 @@
import { redis, utils, tenancy } from "@budibase/backend-core"
import env from "../environment"
function getExpirySecondsForDB(db: string) {
switch (db) {
case redis.utils.Databases.PW_RESETS:
// a hour
return 3600
case redis.utils.Databases.INVITATIONS:
// a week
return 604800
}
}
let pwResetClient: any, invitationClient: any
function getClient(db: string) {
switch (db) {
case redis.utils.Databases.PW_RESETS:
return pwResetClient
case redis.utils.Databases.INVITATIONS:
return invitationClient
}
}
async function writeACode(db: string, value: any) {
const client = await getClient(db)
const code = utils.newid()
await client.store(code, value, getExpirySecondsForDB(db))
return code
}
async function updateACode(db: string, code: string, value: any) {
const client = await getClient(db)
await client.store(code, value, getExpirySecondsForDB(db))
}
/**
* Given an invite code and invite body, allow the update an existing/valid invite in redis
* @param inviteCode The invite code for an invite in redis
* @param value The body of the updated user invitation
*/
export async function updateInviteCode(inviteCode: string, value: string) {
await updateACode(redis.utils.Databases.INVITATIONS, inviteCode, value)
}
async function getACode(db: string, code: string, deleteCode = true) {
const client = await getClient(db)
const value = await client.get(code)
if (!value) {
throw new Error("Invalid code.")
}
if (deleteCode) {
await client.delete(code)
}
return value
}
export async function init() {
pwResetClient = new redis.Client(redis.utils.Databases.PW_RESETS)
invitationClient = new redis.Client(redis.utils.Databases.INVITATIONS)
await pwResetClient.init()
await invitationClient.init()
}
/**
* make sure redis connection is closed.
*/
export async function shutdown() {
if (pwResetClient) await pwResetClient.finish()
if (invitationClient) await invitationClient.finish()
// shutdown core clients
await redis.clients.shutdown()
console.log("Redis shutdown")
}
/**
* Given a user ID this will store a code (that is returned) for an hour in redis.
* The user can then return this code for resetting their password (through their reset link).
* @param userId the ID of the user which is to be reset.
* @param info Info about the user/the reset process.
* @return returns the code that was stored to redis.
*/
export async function getResetPasswordCode(userId: string, info: any) {
return writeACode(redis.utils.Databases.PW_RESETS, { userId, info })
}
/**
* Given a reset code this will lookup to redis, check if the code is valid and delete if required.
* @param resetCode The code provided via the email link.
* @param deleteCode If the code is used/finished with this will delete it - defaults to true.
* @return returns the user ID if it is found
*/
export async function checkResetPasswordCode(
resetCode: string,
deleteCode = true
) {
try {
return getACode(redis.utils.Databases.PW_RESETS, resetCode, deleteCode)
} catch (err) {
throw "Provided information is not valid, cannot reset password - please try again."
}
}
/**
* Generates an invitation code and writes it to redis - which can later be checked for user creation.
* @param email the email address which the code is being sent to (for use later).
* @param info Information to be carried along with the invitation.
* @return returns the code that was stored to redis.
*/
export async function getInviteCode(email: string, info: any) {
return writeACode(redis.utils.Databases.INVITATIONS, { email, info })
}
/**
* Checks that the provided invite code is valid - will return the email address of user that was invited.
* @param inviteCode the invite code that was provided as part of the link.
* @param deleteCode whether or not the code should be deleted after retrieval - defaults to true.
* @return If the code is valid then an email address will be returned.
*/
export async function checkInviteCode(
inviteCode: string,
deleteCode: boolean = true
) {
try {
return getACode(redis.utils.Databases.INVITATIONS, inviteCode, deleteCode)
} catch (err) {
throw "Invitation is not valid or has expired, please request a new one."
}
}
/**
Get all currently available user invitations for the current tenant.
**/
export async function getInviteCodes() {
const client = await getClient(redis.utils.Databases.INVITATIONS)
const invites: any[] = await client.scan()
const results = invites.map(invite => {
return {
...invite.value,
code: invite.key,
}
})
if (!env.MULTI_TENANCY) {
return results
}
const tenantId = tenancy.getTenantId()
return results.filter(invite => tenantId === invite.info.tenantId)
}

View file

@ -983,10 +983,10 @@
expect "^29.0.0"
pretty-format "^29.0.0"
"@types/node-fetch@2.6.2":
version "2.6.2"
resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz"
integrity sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==
"@types/node-fetch@2.6.4":
version "2.6.4"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.4.tgz#1bc3a26de814f6bf466b25aeb1473fa1afe6a660"
integrity sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==
dependencies:
"@types/node" "*"
form-data "^3.0.0"
@ -3587,18 +3587,18 @@ node-duration@^1.0.4:
resolved "https://registry.npmjs.org/node-duration/-/node-duration-1.0.4.tgz"
integrity sha512-eUXYNSY7DL53vqfTosggWkvyIW3bhAcqBDIlolgNYlZhianXTrCL50rlUJWD1eRqkIxMppXTfiFbp+9SjpPrgA==
node-fetch@2, node-fetch@2.6.7, node-fetch@^2.6.7:
node-fetch@2.6.0:
version "2.6.0"
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz"
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
node-fetch@2.6.7, node-fetch@^2.6.7:
version "2.6.7"
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz"
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
dependencies:
whatwg-url "^5.0.0"
node-fetch@2.6.0:
version "2.6.0"
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz"
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
node-gyp-build-optional-packages@5.0.7:
version "5.0.7"
resolved "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz"
@ -4893,10 +4893,10 @@ type-is@^1.6.16, type-is@^1.6.18:
media-typer "0.3.0"
mime-types "~2.1.24"
typescript@4.7.3:
version "4.7.3"
resolved "https://registry.npmjs.org/typescript/-/typescript-4.7.3.tgz"
integrity sha512-WOkT3XYvrpXx4vMMqlD+8R8R37fZkjyLGlxavMc4iB8lrl8L0DeTcHbYgw/v0N/z9wAFsgBhcsF0ruoySS22mA==
typescript@5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78"
integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==
uid2@0.0.x:
version "0.0.4"