From cd7efb03cae554f55760f94829b691ad117f0a17 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Mon, 16 May 2022 21:43:40 -0700 Subject: [PATCH] Implement `onRequest` versions of `createContract`, `placeBet` functions (#227) * Reimplement createContract and placeBet cloud functions * Fix broken warmup function error handling --- functions/package.json | 1 + functions/src/api.ts | 129 ++++++++++++ functions/src/create-contract.ts | 301 +++++++++++++--------------- functions/src/place-bet.ts | 222 ++++++++++---------- web/components/bet-panel.tsx | 2 +- web/components/bets-list.tsx | 5 +- web/components/resolution-panel.tsx | 2 +- web/package.json | 2 +- web/pages/create.tsx | 2 +- yarn.lock | 2 +- 10 files changed, 383 insertions(+), 285 deletions(-) create mode 100644 functions/src/api.ts diff --git a/functions/package.json b/functions/package.json index d51a3481..8df1b8da 100644 --- a/functions/package.json +++ b/functions/package.json @@ -20,6 +20,7 @@ "main": "lib/functions/src/index.js", "dependencies": { "@react-query-firebase/firestore": "0.4.2", + "cors": "2.8.5", "fetch": "1.1.0", "firebase-admin": "10.0.0", "firebase-functions": "3.16.0", diff --git a/functions/src/api.ts b/functions/src/api.ts new file mode 100644 index 00000000..9fc989f3 --- /dev/null +++ b/functions/src/api.ts @@ -0,0 +1,129 @@ +import * as admin from 'firebase-admin' +import * as functions from 'firebase-functions' +import * as Cors from 'cors' + +import { User, PrivateUser } from 'common/user' + +type Request = functions.https.Request +type Response = functions.Response +type Handler = (req: Request, res: Response) => Promise +type AuthedUser = [User, PrivateUser] +type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken } +type KeyCredentials = { kind: 'key'; data: string } +type Credentials = JwtCredentials | KeyCredentials + +export class APIError { + code: number + msg: string + constructor(code: number, msg: string) { + this.code = code + this.msg = msg + } +} + +export const parseCredentials = async (req: Request): Promise => { + const authHeader = req.get('Authorization') + if (!authHeader) { + throw new APIError(403, 'Missing Authorization header.') + } + const authParts = authHeader.split(' ') + if (authParts.length !== 2) { + throw new APIError(403, 'Invalid Authorization header.') + } + + const [scheme, payload] = authParts + switch (scheme) { + case 'Bearer': + try { + const jwt = await admin.auth().verifyIdToken(payload) + if (!jwt.user_id) { + throw new APIError(403, 'JWT must contain Manifold user ID.') + } + return { kind: 'jwt', data: jwt } + } catch (err) { + // This is somewhat suspicious, so get it into the firebase console + functions.logger.error('Error verifying Firebase JWT: ', err) + throw new APIError(403, `Error validating token: ${err}.`) + } + case 'Key': + return { kind: 'key', data: payload } + default: + throw new APIError(403, 'Invalid auth scheme; must be "Key" or "Bearer".') + } +} + +export const lookupUser = async (creds: Credentials): Promise => { + const firestore = admin.firestore() + const users = firestore.collection('users') + const privateUsers = firestore.collection('private-users') + switch (creds.kind) { + case 'jwt': { + const { user_id } = creds.data + const [userSnap, privateUserSnap] = await Promise.all([ + users.doc(user_id).get(), + privateUsers.doc(user_id).get(), + ]) + if (!userSnap.exists || !privateUserSnap.exists) { + throw new APIError(403, 'No user exists with the provided ID.') + } + const user = userSnap.data() as User + const privateUser = privateUserSnap.data() as PrivateUser + return [user, privateUser] + } + case 'key': { + const key = creds.data + const privateUserQ = await privateUsers.where('apiKey', '==', key).get() + if (privateUserQ.empty) { + throw new APIError(403, `No private user exists with API key ${key}.`) + } + const privateUserSnap = privateUserQ.docs[0] + const userSnap = await users.doc(privateUserSnap.id).get() + if (!userSnap.exists) { + throw new APIError(403, `No user exists with ID ${privateUserSnap.id}.`) + } + const user = userSnap.data() as User + const privateUser = privateUserSnap.data() as PrivateUser + return [user, privateUser] + } + default: + throw new APIError(500, 'Invalid credential type.') + } +} + +export const CORS_ORIGIN_MANIFOLD = /^https?:\/\/.+\.manifold\.markets$/ +export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/ + +export const applyCors = (req: any, res: any, params: object) => { + return new Promise((resolve, reject) => { + Cors(params)(req, res, (result) => { + if (result instanceof Error) { + return reject(result) + } + return resolve(result) + }) + }) +} + +export const newEndpoint = (methods: [string], fn: Handler) => + functions.runWith({ minInstances: 1 }).https.onRequest(async (req, res) => { + await applyCors(req, res, { + origins: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], + methods: methods, + }) + try { + if (!methods.includes(req.method)) { + const allowed = methods.join(', ') + throw new APIError(405, `This endpoint supports only ${allowed}.`) + } + const data = await fn(req, res) + data.status = 'success' + res.status(200).json({ data: data }) + } catch (e) { + if (e instanceof APIError) { + // Emit a 200 anyway here for now, for backwards compatibility + res.status(200).json({ data: { status: 'error', message: e.msg } }) + } else { + res.status(500).json({ data: { status: 'error', message: '???' } }) + } + } + }) diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 2ebe6e6c..988624d6 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -1,8 +1,7 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import * as _ from 'lodash' -import { chargeUser, getUser } from './utils' +import { chargeUser } from './utils' +import { APIError, newEndpoint, parseCredentials, lookupUser } from './api' import { Binary, Contract, @@ -13,7 +12,6 @@ import { MAX_DESCRIPTION_LENGTH, MAX_QUESTION_LENGTH, MAX_TAG_LENGTH, - outcomeType, } from '../../common/contract' import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' @@ -28,169 +26,154 @@ import { } from '../../common/antes' import { getNoneAnswer } from '../../common/answer' -export const createContract = functions - .runWith({ minInstances: 1 }) - .https.onCall( - async ( - data: { - question: string - outcomeType: outcomeType - description: string - initialProb: number - ante: number - closeTime: number - tags?: string[] - manaLimitPerUser?: number - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +export const createContract = newEndpoint(['POST'], async (req, _res) => { + const [creator, _privateUser] = await lookupUser(await parseCredentials(req)) + let { + question, + outcomeType, + description, + initialProb, + closeTime, + tags, + manaLimitPerUser, + } = req.body.data || {} - const creator = await getUser(userId) - if (!creator) return { status: 'error', message: 'User not found' } + if (!question || typeof question != 'string') + throw new APIError(400, 'Missing or invalid question field') - let { - question, - description, - initialProb, - closeTime, - tags, - manaLimitPerUser, - } = data + question = question.slice(0, MAX_QUESTION_LENGTH) - if (!question || typeof question != 'string') - return { status: 'error', message: 'Missing or invalid question field' } - question = question.slice(0, MAX_QUESTION_LENGTH) + if (typeof description !== 'string') + throw new APIError(400, 'Invalid description field') - if (typeof description !== 'string') - return { status: 'error', message: 'Invalid description field' } - description = description.slice(0, MAX_DESCRIPTION_LENGTH) + description = description.slice(0, MAX_DESCRIPTION_LENGTH) - if (tags !== undefined && !_.isArray(tags)) - return { status: 'error', message: 'Invalid tags field' } - tags = tags?.map((tag) => tag.toString().slice(0, MAX_TAG_LENGTH)) + if (tags !== undefined && !Array.isArray(tags)) + throw new APIError(400, 'Invalid tags field') - let outcomeType = data.outcomeType ?? 'BINARY' - if (!['BINARY', 'MULTI', 'FREE_RESPONSE'].includes(outcomeType)) - return { status: 'error', message: 'Invalid outcomeType' } - - if ( - outcomeType === 'BINARY' && - (!initialProb || initialProb < 1 || initialProb > 99) - ) - return { status: 'error', message: 'Invalid initial probability' } - - // uses utc time on server: - const today = new Date().setHours(0, 0, 0, 0) - const userContractsCreatedTodaySnapshot = await firestore - .collection(`contracts`) - .where('creatorId', '==', userId) - .where('createdTime', '>=', today) - .get() - const isFree = userContractsCreatedTodaySnapshot.size === 0 - - const ante = FIXED_ANTE // data.ante - - if ( - ante === undefined || - ante < MINIMUM_ANTE || - (ante > creator.balance && !isFree) || - isNaN(ante) || - !isFinite(ante) - ) - return { status: 'error', message: 'Invalid ante' } - - console.log( - 'creating contract for', - creator.username, - 'on', - question, - 'ante:', - ante || 0 - ) - - const slug = await getSlug(question) - - const contractRef = firestore.collection('contracts').doc() - - const contract = getNewContract( - contractRef.id, - slug, - creator, - question, - outcomeType, - description, - initialProb, - ante, - closeTime, - tags ?? [], - manaLimitPerUser ?? 0 - ) - - if (!isFree && ante) await chargeUser(creator.id, ante, true) - - await contractRef.create(contract) - - if (ante) { - if (outcomeType === 'BINARY' && contract.mechanism === 'dpm-2') { - const yesBetDoc = firestore - .collection(`contracts/${contract.id}/bets`) - .doc() - - const noBetDoc = firestore - .collection(`contracts/${contract.id}/bets`) - .doc() - - const { yesBet, noBet } = getAnteBets( - creator, - contract as FullContract, - yesBetDoc.id, - noBetDoc.id - ) - - await yesBetDoc.set(yesBet) - await noBetDoc.set(noBet) - } else if (outcomeType === 'BINARY') { - const liquidityDoc = firestore - .collection(`contracts/${contract.id}/liquidity`) - .doc() - - const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : creator.id - - const lp = getCpmmInitialLiquidity( - providerId, - contract as FullContract, - liquidityDoc.id, - ante - ) - - await liquidityDoc.set(lp) - } else if (outcomeType === 'FREE_RESPONSE') { - const noneAnswerDoc = firestore - .collection(`contracts/${contract.id}/answers`) - .doc('0') - - const noneAnswer = getNoneAnswer(contract.id, creator) - await noneAnswerDoc.set(noneAnswer) - - const anteBetDoc = firestore - .collection(`contracts/${contract.id}/bets`) - .doc() - - const anteBet = getFreeAnswerAnte( - creator, - contract as FullContract, - anteBetDoc.id - ) - await anteBetDoc.set(anteBet) - } - } - - return { status: 'success', contract } - } + tags = (tags || []).map((tag: string) => + tag.toString().slice(0, MAX_TAG_LENGTH) ) + outcomeType = outcomeType ?? 'BINARY' + if (!['BINARY', 'MULTI', 'FREE_RESPONSE'].includes(outcomeType)) + throw new APIError(400, 'Invalid outcomeType') + + if ( + outcomeType === 'BINARY' && + (!initialProb || initialProb < 1 || initialProb > 99) + ) + throw new APIError(400, 'Invalid initial probability') + + // uses utc time on server: + const today = new Date().setHours(0, 0, 0, 0) + const userContractsCreatedTodaySnapshot = await firestore + .collection(`contracts`) + .where('creatorId', '==', creator.id) + .where('createdTime', '>=', today) + .get() + const isFree = userContractsCreatedTodaySnapshot.size === 0 + + const ante = FIXED_ANTE + + if ( + ante === undefined || + ante < MINIMUM_ANTE || + (ante > creator.balance && !isFree) || + isNaN(ante) || + !isFinite(ante) + ) + throw new APIError(400, 'Invalid ante') + + console.log( + 'creating contract for', + creator.username, + 'on', + question, + 'ante:', + ante || 0 + ) + + const slug = await getSlug(question) + + const contractRef = firestore.collection('contracts').doc() + + const contract = getNewContract( + contractRef.id, + slug, + creator, + question, + outcomeType, + description, + initialProb, + ante, + closeTime, + tags ?? [], + manaLimitPerUser ?? 0 + ) + + if (!isFree && ante) await chargeUser(creator.id, ante, true) + + await contractRef.create(contract) + + if (ante) { + if (outcomeType === 'BINARY' && contract.mechanism === 'dpm-2') { + const yesBetDoc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() + + const noBetDoc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() + + const { yesBet, noBet } = getAnteBets( + creator, + contract as FullContract, + yesBetDoc.id, + noBetDoc.id + ) + + await yesBetDoc.set(yesBet) + await noBetDoc.set(noBet) + } else if (outcomeType === 'BINARY') { + const liquidityDoc = firestore + .collection(`contracts/${contract.id}/liquidity`) + .doc() + + const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : creator.id + + const lp = getCpmmInitialLiquidity( + providerId, + contract as FullContract, + liquidityDoc.id, + ante + ) + + await liquidityDoc.set(lp) + } else if (outcomeType === 'FREE_RESPONSE') { + const noneAnswerDoc = firestore + .collection(`contracts/${contract.id}/answers`) + .doc('0') + + const noneAnswer = getNoneAnswer(contract.id, creator) + await noneAnswerDoc.set(noneAnswer) + + const anteBetDoc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() + + const anteBet = getFreeAnswerAnte( + creator, + contract as FullContract, + anteBetDoc.id + ) + await anteBetDoc.set(anteBet) + } + } + + return { contract: contract } +}) + const getSlug = async (question: string) => { const proposedSlug = slugify(question) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index e1ba9fc2..cdaf2215 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -1,6 +1,6 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { APIError, newEndpoint, parseCredentials, lookupUser } from './api' import { Contract } from '../../common/contract' import { User } from '../../common/user' import { @@ -14,146 +14,128 @@ import { redeemShares } from './redeem-shares' import { Fees } from '../../common/fees' import { hasUserHitManaLimit } from '../../common/calculate' -export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( - async ( - data: { - amount: number - outcome: string - contractId: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +export const placeBet = newEndpoint(['POST'], async (req, _res) => { + const [bettor, _privateUser] = await lookupUser(await parseCredentials(req)) + const { amount, outcome, contractId } = req.body.data || {} - const { amount, outcome, contractId } = data + if (amount <= 0 || isNaN(amount) || !isFinite(amount)) + throw new APIError(400, 'Invalid amount') - if (amount <= 0 || isNaN(amount) || !isFinite(amount)) - return { status: 'error', message: 'Invalid amount' } + if (outcome !== 'YES' && outcome !== 'NO' && isNaN(+outcome)) + throw new APIError(400, 'Invalid outcome') - if (outcome !== 'YES' && outcome !== 'NO' && isNaN(+outcome)) - return { status: 'error', message: 'Invalid outcome' } + // run as transaction to prevent race conditions + return await firestore + .runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${bettor.id}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found') + const user = userSnap.data() as User - // run as transaction to prevent race conditions - return await firestore - .runTransaction(async (transaction) => { - const userDoc = firestore.doc(`users/${userId}`) - const userSnap = await transaction.get(userDoc) - if (!userSnap.exists) - return { status: 'error', message: 'User not found' } - const user = userSnap.data() as User + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') + const contract = contractSnap.data() as Contract - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await transaction.get(contractDoc) - if (!contractSnap.exists) - return { status: 'error', message: 'Invalid contract' } - const contract = contractSnap.data() as Contract + const { closeTime, outcomeType, mechanism, collectedFees, volume } = + contract + if (closeTime && Date.now() > closeTime) + throw new APIError(400, 'Trading is closed') - const { closeTime, outcomeType, mechanism, collectedFees, volume } = - contract - if (closeTime && Date.now() > closeTime) - return { status: 'error', message: 'Trading is closed' } + const yourBetsSnap = await transaction.get( + contractDoc.collection('bets').where('userId', '==', bettor.id) + ) + const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet) - const yourBetsSnap = await transaction.get( - contractDoc.collection('bets').where('userId', '==', userId) + const loanAmount = 0 // getLoanAmount(yourBets, amount) + if (user.balance < amount) throw new APIError(400, 'Insufficient balance') + + if (outcomeType === 'FREE_RESPONSE') { + const answerSnap = await transaction.get( + contractDoc.collection('answers').doc(outcome) ) - const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet) + if (!answerSnap.exists) throw new APIError(400, 'Invalid contract') - const loanAmount = 0 // getLoanAmount(yourBets, amount) - if (user.balance < amount) - return { status: 'error', message: 'Insufficient balance' } + const { status, message } = hasUserHitManaLimit( + contract, + yourBets, + amount + ) + if (status === 'error') throw new APIError(400, message) + } - if (outcomeType === 'FREE_RESPONSE') { - const answerSnap = await transaction.get( - contractDoc.collection('answers').doc(outcome) - ) - if (!answerSnap.exists) - return { status: 'error', message: 'Invalid contract' } + const newBetDoc = firestore + .collection(`contracts/${contractId}/bets`) + .doc() - const { status, message } = hasUserHitManaLimit( - contract, - yourBets, - amount - ) - if (status === 'error') return { status, message: message } - } - - const newBetDoc = firestore - .collection(`contracts/${contractId}/bets`) - .doc() - - const { - newBet, - newPool, - newTotalShares, - newTotalBets, - newBalance, - newTotalLiquidity, - fees, - newP, - } = - outcomeType === 'BINARY' - ? mechanism === 'dpm-2' - ? getNewBinaryDpmBetInfo( - user, - outcome as 'YES' | 'NO', - amount, - contract, - loanAmount, - newBetDoc.id - ) - : (getNewBinaryCpmmBetInfo( - user, - outcome as 'YES' | 'NO', - amount, - contract, - loanAmount, - newBetDoc.id - ) as any) - : getNewMultiBetInfo( + const { + newBet, + newPool, + newTotalShares, + newTotalBets, + newBalance, + newTotalLiquidity, + fees, + newP, + } = + outcomeType === 'BINARY' + ? mechanism === 'dpm-2' + ? getNewBinaryDpmBetInfo( user, - outcome, + outcome as 'YES' | 'NO', amount, - contract as any, + contract, loanAmount, newBetDoc.id ) + : (getNewBinaryCpmmBetInfo( + user, + outcome as 'YES' | 'NO', + amount, + contract, + loanAmount, + newBetDoc.id + ) as any) + : getNewMultiBetInfo( + user, + outcome, + amount, + contract as any, + loanAmount, + newBetDoc.id + ) - if (newP !== undefined && !isFinite(newP)) { - return { - status: 'error', - message: 'Trade rejected due to overflow error.', - } - } + if (newP !== undefined && !isFinite(newP)) { + throw new APIError(400, 'Trade rejected due to overflow error.') + } - transaction.create(newBetDoc, newBet) + transaction.create(newBetDoc, newBet) - transaction.update( - contractDoc, - removeUndefinedProps({ - pool: newPool, - p: newP, - totalShares: newTotalShares, - totalBets: newTotalBets, - totalLiquidity: newTotalLiquidity, - collectedFees: addObjects(fees ?? {}, collectedFees ?? {}), - volume: volume + Math.abs(amount), - }) - ) + transaction.update( + contractDoc, + removeUndefinedProps({ + pool: newPool, + p: newP, + totalShares: newTotalShares, + totalBets: newTotalBets, + totalLiquidity: newTotalLiquidity, + collectedFees: addObjects(fees ?? {}, collectedFees ?? {}), + volume: volume + Math.abs(amount), + }) + ) - if (!isFinite(newBalance)) { - throw new Error('Invalid user balance for ' + user.username) - } + if (!isFinite(newBalance)) { + throw new APIError(500, 'Invalid user balance for ' + user.username) + } - transaction.update(userDoc, { balance: newBalance }) + transaction.update(userDoc, { balance: newBalance }) - return { status: 'success', betId: newBetDoc.id } - }) - .then(async (result) => { - await redeemShares(userId, contractId) - return result - }) - } -) + return { betId: newBetDoc.id } + }) + .then(async (result) => { + await redeemShares(bettor.id, contractId) + return result + }) +}) const firestore = admin.firestore() diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 049db14b..06e09e73 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -214,7 +214,7 @@ function BuyPanel(props: { useEffect(() => { // warm up cloud function - placeBet({}).catch() + placeBet({}).catch(() => {}) }, []) useEffect(() => { diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 403cb097..684b506a 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -553,7 +553,10 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) { ) } -const warmUpSellBet = _.throttle(() => sellBet({}).catch(), 5000 /* ms */) +const warmUpSellBet = _.throttle( + () => sellBet({}).catch(() => {}), + 5000 /* ms */ +) function SellButton(props: { contract: Contract; bet: Bet }) { useEffect(() => { diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index 1aaabcfd..356587fa 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -20,7 +20,7 @@ export function ResolutionPanel(props: { }) { useEffect(() => { // warm up cloud function - resolveMarket({} as any).catch() + resolveMarket({} as any).catch(() => {}) }, []) const { contract, className } = props diff --git a/web/package.json b/web/package.json index 479eff72..a240d470 100644 --- a/web/package.json +++ b/web/package.json @@ -23,7 +23,7 @@ "@nivo/line": "0.74.0", "algoliasearch": "4.13.0", "clsx": "1.1.1", - "cors": "^2.8.5", + "cors": "2.8.5", "daisyui": "1.16.4", "dayjs": "1.10.7", "firebase": "9.6.0", diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 4edf1c46..13b6ea90 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -61,7 +61,7 @@ export function NewContract(props: { question: string; tag?: string }) { }, [creator]) useEffect(() => { - createContract({}).catch() // warm up function + createContract({}).catch(() => {}) // warm up function }, []) const [outcomeType, setOutcomeType] = useState('BINARY') diff --git a/yarn.lock b/yarn.lock index 4dbcc9f8..98cdf4c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1874,7 +1874,7 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== -cors@^2.8.5: +cors@2.8.5, cors@^2.8.5: version "2.8.5" resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==