diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 8158360c..9b95438a 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -29,6 +29,9 @@ ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY ARG CLERK_SECRET_KEY ENV CLERK_SECRET_KEY=$CLERK_SECRET_KEY +ARG CLERK_PUBLIC_PEM_KEY +ENV CLERK_PUBLIC_PEM_KEY=$CLERK_PUBLIC_PEM_KEY + ARG SEVENTY_SEVEN_API_KEY ENV SEVENTY_SEVEN_API_KEY=$SEVENTY_SEVEN_API_KEY diff --git a/apps/api/package.json b/apps/api/package.json index ae30c30c..9ef23fa9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,6 +26,7 @@ "fastify": "^4.25.2", "fastify-metrics": "^11.0.0", "ico-to-png": "^0.2.1", + "jsonwebtoken": "^9.0.2", "pino": "^8.17.2", "pino-pretty": "^10.3.1", "ramda": "^0.29.1", @@ -41,6 +42,7 @@ "@openpanel/prettier-config": "workspace:*", "@openpanel/sdk": "workspace:*", "@openpanel/tsconfig": "workspace:*", + "@types/jsonwebtoken": "^9.0.6", "@types/ramda": "^0.29.6", "@types/request-ip": "^0.0.41", "@types/sqlstring": "^2.3.2", diff --git a/apps/api/src/controllers/live.controller.ts b/apps/api/src/controllers/live.controller.ts index a213fadf..d6419c61 100644 --- a/apps/api/src/controllers/live.controller.ts +++ b/apps/api/src/controllers/live.controller.ts @@ -1,4 +1,4 @@ -import { getAuth } from '@clerk/fastify'; +import { validateClerkJwt } from '@/utils/auth'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { escape } from 'sqlstring'; import superjson from 'superjson'; @@ -13,6 +13,7 @@ import { transformMinimalEvent, } from '@openpanel/db'; import { redis, redisPub, redisSub } from '@openpanel/redis'; +import { getProjectAccess } from '@openpanel/trpc'; export function getLiveEventInfo(key: string) { return key.split(':').slice(2) as [string, string]; @@ -106,7 +107,7 @@ export function wsEvents(connection: { socket: WebSocket }) { }); } -export function wsProjectEvents( +export async function wsProjectEvents( connection: { socket: WebSocket; }, @@ -114,10 +115,19 @@ export function wsProjectEvents( Params: { projectId: string; }; + Querystring: { + token?: string; + }; }> ) { - const { params } = req; - const auth = getAuth(req); + const { params, query } = req; + const { token } = query; + const decoded = validateClerkJwt(token); + const userId = decoded?.sub; + const access = await getProjectAccess({ + userId: userId!, + projectId: params.projectId, + }); redisSub.subscribe('event'); @@ -127,7 +137,7 @@ export function wsProjectEvents( const profile = await getProfileById(event.profileId, event.projectId); connection.socket.send( superjson.stringify( - auth.userId + access ? { ...event, profile, diff --git a/apps/api/src/utils/auth.ts b/apps/api/src/utils/auth.ts index 05791b45..5a51fe17 100644 --- a/apps/api/src/utils/auth.ts +++ b/apps/api/src/utils/auth.ts @@ -1,4 +1,5 @@ import type { RawRequestDefaultExpression } from 'fastify'; +import jwt from 'jsonwebtoken'; import { verifyPassword } from '@openpanel/common'; import type { IServiceClient } from '@openpanel/db'; @@ -103,3 +104,23 @@ export async function validateExportRequest( return client; } + +export function validateClerkJwt(token?: string) { + if (!token) { + return null; + } + try { + const decoded = jwt.verify( + token, + process.env.CLERK_PUBLIC_PEM_KEY!.replace(/\\n/g, '\n') + ); + + if (typeof decoded === 'object') { + return decoded; + } + } catch (e) { + // + } + + return null; +} diff --git a/apps/dashboard/src/hooks/useWS.ts b/apps/dashboard/src/hooks/useWS.ts index 21ebb76f..178e46c8 100644 --- a/apps/dashboard/src/hooks/useWS.ts +++ b/apps/dashboard/src/hooks/useWS.ts @@ -1,20 +1,34 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { use, useEffect, useMemo, useState } from 'react'; +import { useAuth } from '@clerk/nextjs'; import useWebSocket from 'react-use-websocket'; import { getSuperJson } from '@openpanel/common'; export default function useWS(path: string, onMessage: (event: T) => void) { + const auth = useAuth(); const ws = String(process.env.NEXT_PUBLIC_API_URL) .replace(/^https/, 'wss') .replace(/^http/, 'ws'); - const [socketUrl, setSocketUrl] = useState(`${ws}${path}`); + const [baseUrl, setBaseUrl] = useState(`${ws}${path}`); + const [token, setToken] = useState(null); + const socketUrl = useMemo( + () => (token ? `${baseUrl}?token=${token}` : baseUrl), + [baseUrl, token] + ); useEffect(() => { - if (socketUrl === `${ws}${path}`) return; - setSocketUrl(`${ws}${path}`); - }, [path, socketUrl, ws]); + if (auth.isSignedIn) { + auth.getToken().then(setToken); + } + }, [auth]); + + useEffect(() => { + if (baseUrl === `${ws}${path}`) return; + setBaseUrl(`${ws}${path}`); + }, [path, baseUrl, ws]); + console.log('socketUrl', socketUrl); useWebSocket(socketUrl, { shouldReconnect: () => true, diff --git a/packages/redis/cachable.ts b/packages/redis/cachable.ts new file mode 100644 index 00000000..a0631796 --- /dev/null +++ b/packages/redis/cachable.ts @@ -0,0 +1,17 @@ +import { redis } from './redis'; + +export function cacheable any>( + fn: T, + expire: number +) { + return async function (...args: Parameters): Promise> { + const key = `cachable:${fn.name}:${JSON.stringify(args)}`; + const cached = await redis.get(key); + if (cached) { + return JSON.parse(cached); + } + const result = await fn(...(args as any)); + redis.setex(key, expire, JSON.stringify(result)); + return result; + }; +} diff --git a/packages/redis/index.ts b/packages/redis/index.ts index 589a6889..df8ac5a6 100644 --- a/packages/redis/index.ts +++ b/packages/redis/index.ts @@ -1,10 +1,2 @@ -import type { RedisOptions } from 'ioredis'; -import Redis from 'ioredis'; - -const options: RedisOptions = { - connectTimeout: 10000, -}; - -export const redis = new Redis(process.env.REDIS_URL!, options); -export const redisSub = new Redis(process.env.REDIS_URL!, options); -export const redisPub = new Redis(process.env.REDIS_URL!, options); +export * from './redis'; +export * from './cachable'; diff --git a/packages/redis/redis.ts b/packages/redis/redis.ts new file mode 100644 index 00000000..589a6889 --- /dev/null +++ b/packages/redis/redis.ts @@ -0,0 +1,10 @@ +import type { RedisOptions } from 'ioredis'; +import Redis from 'ioredis'; + +const options: RedisOptions = { + connectTimeout: 10000, +}; + +export const redis = new Redis(process.env.REDIS_URL!, options); +export const redisSub = new Redis(process.env.REDIS_URL!, options); +export const redisPub = new Redis(process.env.REDIS_URL!, options); diff --git a/packages/trpc/index.ts b/packages/trpc/index.ts index 15db407c..2c70a820 100644 --- a/packages/trpc/index.ts +++ b/packages/trpc/index.ts @@ -1,2 +1,3 @@ export * from './src/root'; export * from './src/trpc'; +export { getProjectAccess } from './src/access'; diff --git a/packages/trpc/package.json b/packages/trpc/package.json index c48234c1..c9ac59b5 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -13,6 +13,7 @@ "@openpanel/constants": "workspace:*", "@openpanel/db": "workspace:*", "@openpanel/validation": "workspace:*", + "@openpanel/redis": "workspace:*", "@seventy-seven/sdk": "0.0.0-beta.2", "@trpc/server": "^10.45.1", "date-fns": "^3.3.1", @@ -44,4 +45,4 @@ ] }, "prettier": "@openpanel/prettier-config" -} +} \ No newline at end of file diff --git a/packages/trpc/src/access.ts b/packages/trpc/src/access.ts new file mode 100644 index 00000000..a956f128 --- /dev/null +++ b/packages/trpc/src/access.ts @@ -0,0 +1,53 @@ +import { clerkClient } from '@clerk/fastify'; + +import { getProjectById } from '@openpanel/db'; +import { cacheable } from '@openpanel/redis'; + +export const getProjectAccessCached = cacheable(getProjectAccess, 60 * 60); +export async function getProjectAccess({ + userId, + projectId, +}: { + userId: string; + projectId: string; +}) { + try { + // Check if user has access to the project + const [project, organizations] = await Promise.all([ + getProjectById(projectId), + clerkClient.users.getOrganizationMembershipList({ + userId, + }), + ]); + + if (!project) { + return false; + } + + return !!organizations.data.find( + (org) => org.organization.slug === project.organizationSlug + ); + } catch (err) { + return false; + } +} + +export const getOrganizationAccessCached = cacheable( + getOrganizationAccess, + 60 * 60 +); +export async function getOrganizationAccess({ + userId, + organizationId, +}: { + userId: string; + organizationId: string; +}) { + const organizations = await clerkClient.users.getOrganizationMembershipList({ + userId, + }); + + return !!organizations.data.find( + (org) => org.organization.id === organizationId + ); +} diff --git a/packages/trpc/src/errors.ts b/packages/trpc/src/errors.ts new file mode 100644 index 00000000..382537e5 --- /dev/null +++ b/packages/trpc/src/errors.ts @@ -0,0 +1,7 @@ +import { TRPCError } from '@trpc/server'; + +export const TRPCAccessError = (message: string) => + new TRPCError({ + code: 'UNAUTHORIZED', + message, + }); diff --git a/packages/trpc/src/routers/chart.ts b/packages/trpc/src/routers/chart.ts index 7fa25ba7..214e17a2 100644 --- a/packages/trpc/src/routers/chart.ts +++ b/packages/trpc/src/routers/chart.ts @@ -3,10 +3,12 @@ import { escape } from 'sqlstring'; import { z } from 'zod'; import { average, max, min, round, slug, sum } from '@openpanel/common'; -import { chQuery, createSqlBuilder } from '@openpanel/db'; +import { chQuery, createSqlBuilder, db } from '@openpanel/db'; import { zChartInput } from '@openpanel/validation'; import type { IChartEvent, IChartInput } from '@openpanel/validation'; +import { getProjectAccessCached } from '../access'; +import { TRPCAccessError } from '../errors'; import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; import { getChartPrevStartEndDate, @@ -111,8 +113,7 @@ export const chartRouter = createTRPCRouter({ )(properties); }), - // TODO: Make this private - values: publicProcedure + values: protectedProcedure .input( z.object({ event: z.string(), @@ -154,7 +155,7 @@ export const chartRouter = createTRPCRouter({ }; }), - funnel: publicProcedure.input(zChartInput).query(async ({ input }) => { + funnel: protectedProcedure.input(zChartInput).query(async ({ input }) => { const currentPeriod = getChartStartEndDate(input); const previousPeriod = getChartPrevStartEndDate({ range: input.range, @@ -172,7 +173,7 @@ export const chartRouter = createTRPCRouter({ }; }), - funnelStep: publicProcedure + funnelStep: protectedProcedure .input( zChartInput.extend({ step: z.number(), @@ -183,8 +184,27 @@ export const chartRouter = createTRPCRouter({ return getFunnelStep({ ...input, ...currentPeriod }); }), - // TODO: Make this private - chart: publicProcedure.input(zChartInput).query(async ({ input }) => { + chart: publicProcedure.input(zChartInput).query(async ({ input, ctx }) => { + if (ctx.session.userId) { + const access = await getProjectAccessCached({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + } else { + const share = await db.shareOverview.findFirst({ + where: { + projectId: input.projectId, + }, + }); + + if (!share) { + throw TRPCAccessError('You do not have access to this project'); + } + } + const currentPeriod = getChartStartEndDate(input); const previousPeriod = getChartPrevStartEndDate({ range: input.range, diff --git a/packages/trpc/src/routers/event.ts b/packages/trpc/src/routers/event.ts index 94a2cf2c..02be520f 100644 --- a/packages/trpc/src/routers/event.ts +++ b/packages/trpc/src/routers/event.ts @@ -3,6 +3,8 @@ import { z } from 'zod'; import { chQuery, convertClickhouseDateToJs, db } from '@openpanel/db'; +import { getProjectAccessCached } from '../access'; +import { TRPCAccessError } from '../errors'; import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; export const eventRouter = createTRPCRouter({ @@ -37,7 +39,27 @@ export const eventRouter = createTRPCRouter({ limit: z.number().default(8), }) ) - .query(async ({ input: { projectId, cursor, limit } }) => { + .query(async ({ input: { projectId, cursor, limit }, ctx }) => { + if (ctx.session.userId) { + const access = await getProjectAccessCached({ + projectId, + userId: ctx.session.userId, + }); + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + } else { + const share = await db.shareOverview.findFirst({ + where: { + projectId, + }, + }); + + if (!share) { + throw TRPCAccessError('You do not have access to this project'); + } + } + const [events, counts] = await Promise.all([ chQuery<{ id: string; diff --git a/packages/trpc/src/routers/profile.ts b/packages/trpc/src/routers/profile.ts index 7776c132..aed54b43 100644 --- a/packages/trpc/src/routers/profile.ts +++ b/packages/trpc/src/routers/profile.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { chQuery, createSqlBuilder } from '@openpanel/db'; -import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; +import { createTRPCRouter, protectedProcedure } from '../trpc'; export const profileRouter = createTRPCRouter({ properties: protectedProcedure @@ -28,7 +28,7 @@ export const profileRouter = createTRPCRouter({ )(properties); }), - values: publicProcedure + values: protectedProcedure .input( z.object({ property: z.string(), diff --git a/packages/trpc/src/trpc.ts b/packages/trpc/src/trpc.ts index 5245fb38..1cde624d 100644 --- a/packages/trpc/src/trpc.ts +++ b/packages/trpc/src/trpc.ts @@ -1,9 +1,13 @@ import { getAuth } from '@clerk/fastify'; import { initTRPC, TRPCError } from '@trpc/server'; import type { CreateFastifyContextOptions } from '@trpc/server/adapters/fastify'; +import { has } from 'ramda'; import superjson from 'superjson'; import { ZodError } from 'zod'; +import { getProjectAccessCached } from './access'; +import { TRPCAccessError } from './errors'; + export function createContext({ req, res }: CreateFastifyContextOptions) { return { req, @@ -41,10 +45,11 @@ const t = initTRPC.context().create({ }, }); -const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => { +const enforceUserIsAuthed = t.middleware(async ({ ctx, next, input }) => { if (!ctx.session?.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' }); } + try { return next({ ctx: { @@ -60,7 +65,25 @@ const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => { } }); +// Only used on protected routes +const enforceProjectAccess = t.middleware(async ({ ctx, next, rawInput }) => { + if (has('projectId', rawInput)) { + const access = await getProjectAccessCached({ + userId: ctx.session.userId!, + projectId: rawInput.projectId as string, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + } + + return next(); +}); + export const createTRPCRouter = t.router; export const publicProcedure = t.procedure; -export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); +export const protectedProcedure = t.procedure + .use(enforceUserIsAuthed) + .use(enforceProjectAccess); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce5a3e09..77272f35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: ico-to-png: specifier: ^0.2.1 version: 0.2.1 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 pino: specifier: ^8.17.2 version: 8.19.0 @@ -111,6 +114,9 @@ importers: '@openpanel/tsconfig': specifier: workspace:* version: link:../../tooling/typescript + '@types/jsonwebtoken': + specifier: ^9.0.6 + version: 9.0.6 '@types/ramda': specifier: ^0.29.6 version: 0.29.10 @@ -1243,6 +1249,9 @@ importers: '@openpanel/db': specifier: workspace:* version: link:../db + '@openpanel/redis': + specifier: workspace:* + version: link:../redis '@openpanel/validation': specifier: workspace:* version: link:../validation @@ -7531,6 +7540,12 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: false + /@types/jsonwebtoken@9.0.6: + resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} + dependencies: + '@types/node': 18.19.17 + dev: true + /@types/katex@0.16.7: resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} dev: false @@ -8618,6 +8633,10 @@ packages: buffer-fill: 1.0.0 dev: false + /buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + dev: false + /buffer-fill@1.0.0: resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} dev: false @@ -10012,6 +10031,12 @@ packages: /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + /ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} dev: false @@ -12832,6 +12857,22 @@ packages: graceful-fs: 4.2.11 dev: false + /jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + 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.1.1 + ms: 2.1.3 + semver: 7.6.0 + dev: false + /jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -12842,6 +12883,21 @@ packages: object.values: 1.1.7 dev: false + /jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: false + + /jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + dev: false + /katex@0.16.9: resolution: {integrity: sha512-fsSYjWS0EEOwvy81j3vRA8TEAhQhKiqO+FQaKWp0m39qwOzHVBgAUBIXWj1pB+O2W3fIpNa6Y9KSKCVbfPhyAQ==} hasBin: true @@ -13073,17 +13129,40 @@ packages: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} dev: false + /lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + dev: false + /lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} dev: false + /lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + dev: false + + /lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + dev: false + + /lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + dev: false + /lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - dev: true + + /lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + dev: false /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + /lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + dev: false + /lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} dev: true