add better access control
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<T>(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<string | null>(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,
|
||||
|
||||
17
packages/redis/cachable.ts
Normal file
17
packages/redis/cachable.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { redis } from './redis';
|
||||
|
||||
export function cacheable<T extends (...args: any) => any>(
|
||||
fn: T,
|
||||
expire: number
|
||||
) {
|
||||
return async function (...args: Parameters<T>): Promise<ReturnType<T>> {
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
10
packages/redis/redis.ts
Normal file
10
packages/redis/redis.ts
Normal file
@@ -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);
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './src/root';
|
||||
export * from './src/trpc';
|
||||
export { getProjectAccess } from './src/access';
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
53
packages/trpc/src/access.ts
Normal file
53
packages/trpc/src/access.ts
Normal file
@@ -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
|
||||
);
|
||||
}
|
||||
7
packages/trpc/src/errors.ts
Normal file
7
packages/trpc/src/errors.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
export const TRPCAccessError = (message: string) =>
|
||||
new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message,
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<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);
|
||||
|
||||
81
pnpm-lock.yaml
generated
81
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user