diff --git a/README.md b/README.md index 89a43bc..282b970 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,24 @@ Backend for movie-web ## Todo list - - [ ] endpoint to consume provider metrics - - [ ] register endpoint - - [ ] remove user endpoint - - [ ] ratelimits for all endpoints - - [ ] metrics for all http requests - - [ ] session CRUD endpoints - - [ ] data save endpoints + - [ ] standard endpoints: + - [ ] make account (PFP, account name) + - [ ] login + - [X] logout a session + - [ ] read all sessions from logged in user + - [ ] edit current session device name + - [ ] edit account name and PFP + - [ ] delete logged in user + - [ ] backend meta (name and description) + - [ ] upsert settings + - [ ] upsert watched items + - [ ] upsert bookmarks + - [ ] consume provider metrics + - [ ] prometheus metrics + - [ ] requests + - [ ] user count + - [ ] provider metrics + - [ ] ratelimits (stored in redis) + - [ ] switch to pnpm + - [ ] think of privacy centric method of auth + - [ ] global namespacing (accounts are stored on a namespace) diff --git a/package-lock.json b/package-lock.json index 9027b5f..12f7b2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@mikro-orm/postgresql": "^5.9.0", "fastify": "^4.21.0", "fastify-type-provider-zod": "^1.1.9", + "jsonwebtoken": "^9.0.2", "neat-config": "^2.0.0", "type-fest": "^4.2.0", "winston": "^3.10.0", @@ -20,6 +21,7 @@ "zod": "^3.22.2" }, "devDependencies": { + "@types/jsonwebtoken": "^9.0.4", "@types/node": "^20.5.3", "@typescript-eslint/eslint-plugin": "^6.4.1", "@typescript-eslint/parser": "^6.4.1", @@ -599,6 +601,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.4.tgz", + "integrity": "sha512-8UYapdmR0QlxgvJmyE8lP7guxD0UGVMfknsdtCFZh4ovShdBl3iOI4zdvqBHrB/IS+xUj3PSx73Qkey1fhWz+g==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "20.5.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.3.tgz", @@ -1118,6 +1129,11 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-writer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", @@ -1478,6 +1494,14 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2663,6 +2687,46 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/knex": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/knex/-/knex-2.5.1.tgz", @@ -2781,6 +2845,36 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2788,6 +2882,11 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/logform": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/logform/-/logform-2.5.1.tgz", diff --git a/package.json b/package.json index ffeb50c..cfa7ce3 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "build:compile": "tsc && tsc-alias" }, "devDependencies": { + "@types/jsonwebtoken": "^9.0.4", "@types/node": "^20.5.3", "@typescript-eslint/eslint-plugin": "^6.4.1", "@typescript-eslint/parser": "^6.4.1", @@ -36,6 +37,7 @@ "@mikro-orm/postgresql": "^5.9.0", "fastify": "^4.21.0", "fastify-type-provider-zod": "^1.1.9", + "jsonwebtoken": "^9.0.2", "neat-config": "^2.0.0", "type-fest": "^4.2.0", "winston": "^3.10.0", diff --git a/src/config/fragments/dev.ts b/src/config/fragments/dev.ts index eb8bb14..3e72304 100644 --- a/src/config/fragments/dev.ts +++ b/src/config/fragments/dev.ts @@ -12,4 +12,7 @@ export const devFragment: FragmentSchema = { postgres: { syncSchema: true, }, + crypto: { + sessionSecret: 'aINCithRivERecKENdmANDRaNKenSiNi', + }, }; diff --git a/src/config/schema.ts b/src/config/schema.ts index 57fb8fa..3ae5569 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -38,4 +38,8 @@ export const configSchema = z.object({ // it is extremely destructive, do not use it EVER in production syncSchema: z.coerce.boolean().default(false), }), + crypto: z.object({ + // session secret. used for signing session tokens + sessionSecret: z.string().min(32), + }), }); diff --git a/src/db/models/Session.ts b/src/db/models/Session.ts new file mode 100644 index 0000000..51041a0 --- /dev/null +++ b/src/db/models/Session.ts @@ -0,0 +1,46 @@ +import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; +import { randomUUID } from 'crypto'; + +@Entity({ tableName: 'sessions' }) +export class Session { + @PrimaryKey({ name: 'id', type: 'uuid' }) + id: string = randomUUID(); + + @Property({ name: 'user', type: 'uuid' }) + user!: string; + + @Property({ type: 'date' }) + createdAt: Date = new Date(); + + @Property({ type: 'date' }) + accessedAt!: Date; + + @Property({ type: 'date' }) + expiresAt!: Date; + + @Property({ type: 'text' }) + device!: string; + + @Property({ type: 'text' }) + userAgent!: string; +} + +export interface SessionDTO { + id: string; + user: string; + createdAt: string; + accessedAt: string; + device: string; + userAgent: string; +} + +export function formatSession(session: Session): SessionDTO { + return { + id: session.id, + user: session.id, + createdAt: session.createdAt.toISOString(), + accessedAt: session.accessedAt.toISOString(), + device: session.device, + userAgent: session.userAgent, + }; +} diff --git a/src/routes/auth/manage.ts b/src/routes/auth/manage.ts index 251c5b4..614f97d 100644 --- a/src/routes/auth/manage.ts +++ b/src/routes/auth/manage.ts @@ -1,6 +1,8 @@ +import { formatSession } from '@/db/models/Session'; import { User, formatUser } from '@/db/models/User'; import { handle } from '@/services/handler'; import { makeRouter } from '@/services/router'; +import { makeSession, makeSessionToken } from '@/services/session'; import { z } from 'zod'; const registerSchema = z.object({ @@ -12,13 +14,22 @@ export const manageAuthRouter = makeRouter((app) => { app.post( '/auth/register', { schema: { body: registerSchema } }, - handle(({ em, body }) => { + handle(async ({ em, body, req }) => { const user = new User(); user.name = body.name; - em.persistAndFlush(user); + const session = makeSession( + user.id, + body.device, + req.headers['user-agent'], + ); + + em.persist([user, session]); + await em.flush(); return { user: formatUser(user), + session: formatSession(session), + token: makeSessionToken(session), }; }), ); diff --git a/src/routes/auth/session.ts b/src/routes/auth/session.ts new file mode 100644 index 0000000..0b4b100 --- /dev/null +++ b/src/routes/auth/session.ts @@ -0,0 +1,30 @@ +import { Session } from '@/db/models/Session'; +import { StatusError } from '@/services/error'; +import { handle } from '@/services/handler'; +import { makeRouter } from '@/services/router'; +import { z } from 'zod'; + +export const sessionRouter = makeRouter((app) => { + app.delete( + '/auth/session/:sid', + { + schema: { + params: z.object({ + sid: z.string(), + }), + }, + }, + handle(async ({ auth, params, em }) => { + auth.assert(); + + const targetedSession = await em.findOne(Session, { id: params.sid }); + if (!targetedSession) return true; // already deleted + + if (targetedSession.user !== auth.user.id) + throw new StatusError('Cant delete sessions you dont own', 401); + + await em.removeAndFlush(targetedSession); + return true; + }), + ); +}); diff --git a/src/services/auth.ts b/src/services/auth.ts index e6c5109..789ea26 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,5 +1,62 @@ +import { Session } from '@/db/models/Session'; +import { User } from '@/db/models/User'; import { Roles } from '@/services/access'; +import { StatusError } from '@/services/error'; +import { getSessionAndBump, verifySessionToken } from '@/services/session'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { FastifyRequest } from 'fastify'; -export function assertHasRole(_role: Roles) { - throw new Error('requires role'); +export function makeAuthContext(manager: EntityManager, req: FastifyRequest) { + let userCache: User | null = null; + let sessionCache: Session | null = null; + const em = manager.fork(); + + return { + getSessionId(): string | null { + const header = req.headers.authorization; + if (!header) return null; + const [type, token] = header.split(' ', 2); + if (type.toLowerCase() !== 'Bearer') + throw new StatusError('Invalid auth', 400); + const payload = verifySessionToken(token); + if (!payload) throw new StatusError('Invalid auth', 400); + return payload.sid; + }, + async getSession() { + if (sessionCache) return sessionCache; + const sid = this.getSessionId(); + if (!sid) return null; + const session = await getSessionAndBump(em, sid); + if (session) return null; + sessionCache = session; + return session; + }, + async getUser() { + if (userCache) return userCache; + const session = await this.getSession(); + if (!session) return null; + const user = await em.findOne(User, { id: session.user }); + if (!user) return null; + userCache = user; + return user; + }, + async assert() { + const user = await this.getUser(); + if (!user) throw new StatusError('Not logged in', 403); + return user; + }, + get user() { + if (!userCache) throw new Error('call assert before getting user'); + return userCache; + }, + get session() { + if (!sessionCache) throw new Error('call assert before getting session'); + return sessionCache; + }, + async assertHasRole(role: Roles) { + const user = await this.assert(); + const hasRole = user.roles.includes(role); + if (!hasRole) throw new StatusError('No permissions', 401); + }, + }; } diff --git a/src/services/error.ts b/src/services/error.ts new file mode 100644 index 0000000..b9bcf96 --- /dev/null +++ b/src/services/error.ts @@ -0,0 +1,9 @@ +export class StatusError extends Error { + errorStatusCode: number; + + constructor(message: string, code: number) { + super(message); + this.errorStatusCode = code; + this.message = message; + } +} diff --git a/src/services/handler.ts b/src/services/handler.ts index 04f7a1b..d944221 100644 --- a/src/services/handler.ts +++ b/src/services/handler.ts @@ -1,4 +1,5 @@ import { getORM } from '@/modules/mikro'; +import { makeAuthContext } from '@/services/auth'; import { EntityManager } from '@mikro-orm/postgresql'; import { ContextConfigDefault, @@ -73,6 +74,7 @@ export type RequestContext< Logger >['query']; em: EntityManager; + auth: ReturnType; }; export function handle< @@ -112,6 +114,7 @@ export function handle< Logger > { const reqHandler: any = async (req: any, res: any) => { + const em = getORM().em.fork(); res.send( await handler({ req, @@ -119,7 +122,8 @@ export function handle< body: req.body, params: req.params, query: req.query, - em: getORM().em.fork(), + em, + auth: makeAuthContext(em, req), }), ); }; diff --git a/src/services/session.ts b/src/services/session.ts new file mode 100644 index 0000000..1ad3a4b --- /dev/null +++ b/src/services/session.ts @@ -0,0 +1,69 @@ +import { conf } from '@/config'; +import { Session } from '@/db/models/Session'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { sign, verify } from 'jsonwebtoken'; + +// 21 days in ms +const SESSION_EXPIRY_MS = 21 * 24 * 60 * 60 * 1000; + +export async function getSession( + em: EntityManager, + id: string, +): Promise { + const session = await em.findOne(Session, { id }); + if (!session) return null; + + if (session.expiresAt < new Date()) return null; + + return session; +} + +export async function getSessionAndBump( + em: EntityManager, + id: string, +): Promise { + const session = await getSession(em, id); + if (!session) return null; + em.assign(session, { + accessedAt: new Date(), + expiresAt: new Date(Date.now() + SESSION_EXPIRY_MS), + }); + await em.flush(); + return session; +} + +export function makeSession( + user: string, + device: string, + userAgent?: string, +): Session { + if (!userAgent) throw new Error('No useragent provided'); + + const session = new Session(); + session.accessedAt = new Date(); + session.createdAt = new Date(); + session.expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); + session.userAgent = userAgent; + session.device = device; + session.user = user; + + return session; +} + +export function makeSessionToken(session: Session): string { + return sign({ sid: session.id }, conf.crypto.sessionSecret, { + algorithm: 'ES512', + }); +} + +export function verifySessionToken(token: string): { sid: string } | null { + try { + const payload = verify(token, conf.crypto.sessionSecret, { + algorithms: ['ES512'], + }); + if (typeof payload === 'string') return null; + return payload as { sid: string }; + } catch { + return null; + } +} diff --git a/yarn.lock b/yarn.lock index 33e7d78..ff73872 100644 --- a/yarn.lock +++ b/yarn.lock @@ -265,6 +265,13 @@ resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz" integrity sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA== +"@types/jsonwebtoken@^9.0.4": + version "9.0.4" + resolved "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.4.tgz" + integrity sha512-8UYapdmR0QlxgvJmyE8lP7guxD0UGVMfknsdtCFZh4ovShdBl3iOI4zdvqBHrB/IS+xUj3PSx73Qkey1fhWz+g== + dependencies: + "@types/node" "*" + "@types/node@*", "@types/node@^20.5.3": version "20.5.3" resolved "https://registry.npmjs.org/@types/node/-/node-20.5.3.tgz" @@ -549,6 +556,11 @@ braces@^3.0.2, braces@~3.0.2: dependencies: fill-range "^7.0.1" +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + buffer-writer@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz" @@ -760,6 +772,13 @@ eastasianwidth@^0.2.0: resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" @@ -1429,6 +1448,39 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonwebtoken@^9.0.2: + version "9.0.2" + resolved "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz" + integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + knex@2.5.1: version "2.5.1" resolved "https://registry.npmjs.org/knex/-/knex-2.5.1.tgz" @@ -1478,11 +1530,46 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + lodash@^4.17.21: version "4.17.21" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" @@ -2078,7 +2165,7 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -safe-buffer@~5.2.0: +safe-buffer@^5.0.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==