mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-23 21:02:09 +03:00
feat(server): port resolvers to node server (#2026)
Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
parent
3df3498523
commit
b4bb57b2a5
@ -1 +1,2 @@
|
||||
SECRET_KEY="secret"
|
||||
DATABASE_URL="postgresql://affine@localhost:5432/affine"
|
||||
|
@ -1,67 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "google_users" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"user_id" VARCHAR NOT NULL,
|
||||
"google_id" VARCHAR NOT NULL,
|
||||
|
||||
CONSTRAINT "google_users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "permissions" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"workspace_id" VARCHAR NOT NULL,
|
||||
"user_id" VARCHAR,
|
||||
"user_email" TEXT,
|
||||
"type" SMALLINT NOT NULL,
|
||||
"accepted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "seaql_migrations" (
|
||||
"version" VARCHAR NOT NULL,
|
||||
"applied_at" BIGINT NOT NULL,
|
||||
|
||||
CONSTRAINT "seaql_migrations_pkey" PRIMARY KEY ("version")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"name" VARCHAR NOT NULL,
|
||||
"email" VARCHAR NOT NULL,
|
||||
"avatar_url" VARCHAR,
|
||||
"token_nonce" SMALLINT DEFAULT 0,
|
||||
"password" VARCHAR,
|
||||
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "workspaces" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"public" BOOLEAN NOT NULL,
|
||||
"type" SMALLINT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "google_users_google_id_key" ON "google_users"("google_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "google_users" ADD CONSTRAINT "google_users_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "permissions" ADD CONSTRAINT "permissions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "permissions" ADD CONSTRAINT "permissions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
59
apps/server/migrations/20230425035217_init/migration.sql
Normal file
59
apps/server/migrations/20230425035217_init/migration.sql
Normal file
@ -0,0 +1,59 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"name" VARCHAR NOT NULL,
|
||||
"email" VARCHAR NOT NULL,
|
||||
"token_nonce" SMALLINT NOT NULL DEFAULT 0,
|
||||
"avatar_url" VARCHAR,
|
||||
"password" VARCHAR,
|
||||
"fulfilled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "workspaces" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"public" BOOLEAN NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "connected_accounts" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"provider" VARCHAR NOT NULL,
|
||||
"provider_user_id" VARCHAR NOT NULL,
|
||||
|
||||
CONSTRAINT "connected_accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_workspace_permissions" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"workspace_id" VARCHAR NOT NULL,
|
||||
"entity_id" VARCHAR NOT NULL,
|
||||
"type" SMALLINT NOT NULL,
|
||||
"accepted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_workspace_permissions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "connected_accounts_provider_user_id_key" ON "connected_accounts"("provider_user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "connected_accounts" ADD CONSTRAINT "connected_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_workspace_permissions" ADD CONSTRAINT "user_workspace_permissions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_workspace_permissions" ADD CONSTRAINT "user_workspace_permissions_entity_id_fkey" FOREIGN KEY ("entity_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -10,7 +10,8 @@
|
||||
"scripts": {
|
||||
"dev": "nodemon ./src/index.ts",
|
||||
"test": "yarn exec ts-node-esm ./scripts/run-test.ts all",
|
||||
"test:coverage": "c8 yarn exec ts-node-esm ./scripts/run-test.ts all"
|
||||
"test:coverage": "c8 yarn exec ts-node-esm ./scripts/run-test.ts all",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.7.0",
|
||||
@ -21,8 +22,10 @@
|
||||
"@nestjs/platform-express": "^9.4.0",
|
||||
"@prisma/client": "^4.13.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-type-json": "^0.3.2",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"prisma": "^4.13.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
@ -30,6 +33,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^9.4.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jsonwebtoken": "^9.0.1",
|
||||
"@types/lodash-es": "^4.17.7",
|
||||
"@types/node": "^18.16.2",
|
||||
"@types/supertest": "^2.0.12",
|
||||
|
@ -7,46 +7,57 @@ generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model google_users {
|
||||
id String @id @db.VarChar
|
||||
user_id String @db.VarChar
|
||||
google_id String @unique @db.VarChar
|
||||
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
name String @db.VarChar
|
||||
email String @unique @db.VarChar
|
||||
tokenNonce Int @default(0) @map("token_nonce") @db.SmallInt
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar
|
||||
/// Available if user signed up through OAuth providers
|
||||
password String? @db.VarChar
|
||||
/// User may created by email collobration invitation before signup.
|
||||
/// We will precreate a user entity in such senarios but leave fulfilled as false until they signed up
|
||||
/// This implementation is convenient for handing unregistered user permissoin
|
||||
fulfilled Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
connectedAccounts ConnectedAccount[]
|
||||
workspaces UserWorkspacePermission[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model permissions {
|
||||
id String @id @db.VarChar
|
||||
workspace_id String @db.VarChar
|
||||
user_id String? @db.VarChar
|
||||
user_email String?
|
||||
type Int @db.SmallInt
|
||||
accepted Boolean @default(false)
|
||||
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||
users users? @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
workspaces workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade)
|
||||
model Workspace {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
public Boolean
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
users UserWorkspacePermission[]
|
||||
|
||||
@@map("workspaces")
|
||||
}
|
||||
|
||||
model seaql_migrations {
|
||||
version String @id @db.VarChar
|
||||
applied_at BigInt
|
||||
model ConnectedAccount {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
userId String @map("user_id")
|
||||
/// the general provider name, e.g. google, github, facebook
|
||||
provider String @db.VarChar
|
||||
/// the user id provided by OAuth providers, or other user identitive credential like `username` provided by GitHub
|
||||
providerUserId String @unique @map("provider_user_id") @db.VarChar
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("connected_accounts")
|
||||
}
|
||||
|
||||
model users {
|
||||
id String @id @db.VarChar
|
||||
name String @db.VarChar
|
||||
email String @unique @db.VarChar
|
||||
avatar_url String? @db.VarChar
|
||||
token_nonce Int? @default(0) @db.SmallInt
|
||||
password String? @db.VarChar
|
||||
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||
google_users google_users[]
|
||||
permissions permissions[]
|
||||
}
|
||||
model UserWorkspacePermission {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
userId String @map("entity_id") @db.VarChar
|
||||
/// Read/Write/Admin/Owner
|
||||
type Int @db.SmallInt
|
||||
/// Whether the permission invitation is accepted by the user
|
||||
accepted Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
model workspaces {
|
||||
id String @id @db.VarChar
|
||||
public Boolean
|
||||
type Int @db.SmallInt
|
||||
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||
permissions permissions[]
|
||||
@@map("user_workspace_permissions")
|
||||
}
|
||||
|
@ -1,15 +1,10 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import userA from '@affine-test/fixtures/userA.json' assert { type: 'json' };
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
await prisma.users.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
...userA,
|
||||
},
|
||||
await prisma.user.create({
|
||||
data: userA,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
/// <reference types="./global.d.ts" />
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ConfigModule } from './config';
|
||||
|
@ -69,6 +69,10 @@ export function parseEnvValue(value: string | undefined, type?: EnvConfigType) {
|
||||
*/
|
||||
export interface AFFiNEConfig {
|
||||
ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>;
|
||||
/**
|
||||
* Application sign key secret
|
||||
*/
|
||||
readonly secret: string;
|
||||
/**
|
||||
* System version
|
||||
*/
|
||||
|
@ -2,6 +2,7 @@ import pkg from '../../package.json' assert { type: 'json' };
|
||||
import type { AFFiNEConfig } from './def';
|
||||
|
||||
export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
|
||||
secret: 'secret',
|
||||
version: pkg.version,
|
||||
ENV_MAP: {},
|
||||
env: process.env.NODE_ENV ?? 'development',
|
||||
|
5
apps/server/src/global.d.ts
vendored
Normal file
5
apps/server/src/global.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
declare namespace Express {
|
||||
interface Request {
|
||||
user?: import('@prisma/client').User | null;
|
||||
}
|
||||
}
|
82
apps/server/src/modules/auth/guard.ts
Normal file
82
apps/server/src/modules/auth/guard.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import {
|
||||
CanActivate,
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { getRequestResponseFromContext } from '../../utils/nestjs';
|
||||
import { AuthService } from './service';
|
||||
|
||||
export function getUserFromContext(context: ExecutionContext) {
|
||||
const req = getRequestResponseFromContext(context).req;
|
||||
return req.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to fetch current user from the request context.
|
||||
*
|
||||
* > The user may be undefined if authorization token is not provided.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* // Graphql Query
|
||||
* \@Query(() => UserType)
|
||||
* user(@CurrentUser() user?: User) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ```typescript
|
||||
* // HTTP Controller
|
||||
* \@Get('/user)
|
||||
* user(@CurrentUser() user?: User) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) => {
|
||||
return getUserFromContext(context);
|
||||
}
|
||||
);
|
||||
|
||||
@Injectable()
|
||||
class AuthGuard implements CanActivate {
|
||||
constructor(private auth: AuthService, private prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const { req } = getRequestResponseFromContext(context);
|
||||
const token = req.headers.authorization;
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const claims = this.auth.verify(token);
|
||||
req.user = await this.prisma.user.findUnique({ where: { id: claims.id } });
|
||||
return !!req.user;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This guard is used to protect routes/queries/mutations that require a user to be logged in.
|
||||
*
|
||||
* The `@CurrentUser()` parameter decorator used in a `Auth` guarded queries would always give us the user because the `Auth` guard will
|
||||
* fast throw if user is not logged in.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* \@Auth()
|
||||
* \@Query(() => UserType)
|
||||
* user(@CurrentUser() user: User) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const Auth = () => {
|
||||
return UseGuards(AuthGuard);
|
||||
};
|
12
apps/server/src/modules/auth/index.ts
Normal file
12
apps/server/src/modules/auth/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { AuthResolver } from './resolver';
|
||||
import { AuthService } from './service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuthService, AuthResolver],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
export * from './guard';
|
54
apps/server/src/modules/auth/resolver.ts
Normal file
54
apps/server/src/modules/auth/resolver.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Context,
|
||||
Field,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Parent,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { Request } from 'express';
|
||||
|
||||
import { UserType } from '../users/resolver';
|
||||
import { CurrentUser } from './guard';
|
||||
import { AuthService } from './service';
|
||||
|
||||
@ObjectType()
|
||||
export class TokenType {
|
||||
@Field()
|
||||
token!: string;
|
||||
|
||||
@Field()
|
||||
refresh!: string;
|
||||
}
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class AuthResolver {
|
||||
constructor(private auth: AuthService) {}
|
||||
|
||||
@ResolveField(() => TokenType)
|
||||
token(@CurrentUser() currentUser: UserType, @Parent() user: UserType) {
|
||||
if (user !== currentUser) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return {
|
||||
token: this.auth.sign(user),
|
||||
// TODO: impl
|
||||
refresh: '',
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => UserType)
|
||||
async signIn(
|
||||
@Context() ctx: { req: Request },
|
||||
@Args('email') email: string,
|
||||
@Args('password') password: string
|
||||
) {
|
||||
const user = await this.auth.signIn(email, password);
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
}
|
45
apps/server/src/modules/auth/service.ts
Normal file
45
apps/server/src/modules/auth/service.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { User } from '@prisma/client';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma';
|
||||
|
||||
type UserClaim = Pick<User, 'id' | 'name' | 'email'>;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private config: Config, private prisma: PrismaService) {}
|
||||
|
||||
sign(user: UserClaim) {
|
||||
return jwt.sign(user, this.config.secret);
|
||||
}
|
||||
|
||||
verify(token: string) {
|
||||
try {
|
||||
const claims = jwt.verify(token, this.config.secret) as UserClaim;
|
||||
return claims;
|
||||
} catch (e) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
async signIn(email: string, password: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email or password');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { AuthModule } from './auth';
|
||||
import { UsersModule } from './users';
|
||||
import { WorkspaceModule } from './workspaces';
|
||||
|
||||
export const BusinessModules = [WorkspaceModule, UsersModule];
|
||||
export const BusinessModules = [AuthModule, WorkspaceModule, UsersModule];
|
||||
|
@ -1,36 +1,36 @@
|
||||
import { Args, Field, ID, ObjectType, Query, Resolver } from '@nestjs/graphql';
|
||||
import type { users } from '@prisma/client';
|
||||
import type { User } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from '../../prisma/service';
|
||||
|
||||
@ObjectType()
|
||||
export class User implements users {
|
||||
export class UserType implements Partial<User> {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field({ description: 'User name' })
|
||||
name!: string;
|
||||
|
||||
@Field({ description: 'User email' })
|
||||
email!: string;
|
||||
@Field({ description: 'User password', nullable: true })
|
||||
password!: string;
|
||||
|
||||
@Field({ description: 'User avatar url', nullable: true })
|
||||
avatar_url!: string;
|
||||
@Field({ description: 'User token nonce', nullable: true })
|
||||
token_nonce!: number;
|
||||
avatarUrl!: string;
|
||||
|
||||
@Field({ description: 'User created date', nullable: true })
|
||||
created_at!: Date;
|
||||
createdAt!: Date;
|
||||
}
|
||||
|
||||
@Resolver(() => User)
|
||||
@Resolver(() => UserType)
|
||||
export class UserResolver {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Query(() => User, {
|
||||
@Query(() => UserType, {
|
||||
name: 'user',
|
||||
description: 'Get user by email',
|
||||
})
|
||||
async user(@Args('email') email: string) {
|
||||
return this.prisma.users.findUnique({
|
||||
return this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
}
|
||||
|
@ -1,8 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PermissionService } from './permission';
|
||||
import { WorkspaceResolver } from './resolver';
|
||||
|
||||
@Module({
|
||||
providers: [WorkspaceResolver],
|
||||
providers: [WorkspaceResolver, PermissionService],
|
||||
exports: [PermissionService],
|
||||
})
|
||||
export class WorkspaceModule {}
|
||||
export { WorkspaceType } from './resolver';
|
||||
|
134
apps/server/src/modules/workspaces/permission.ts
Normal file
134
apps/server/src/modules/workspaces/permission.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { Permission } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async get(ws: string, user: string) {
|
||||
const data = await this.prisma.userWorkspacePermission.findFirst({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
return data?.type as Permission;
|
||||
}
|
||||
|
||||
async check(
|
||||
ws: string,
|
||||
user: string,
|
||||
permission: Permission = Permission.Read
|
||||
) {
|
||||
if (!(await this.tryCheck(ws, user, permission))) {
|
||||
throw new ForbiddenException('Permission denied');
|
||||
}
|
||||
}
|
||||
|
||||
async tryCheck(
|
||||
ws: string,
|
||||
user: string,
|
||||
permission: Permission = Permission.Read
|
||||
) {
|
||||
const data = await this.prisma.userWorkspacePermission.count({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
accepted: true,
|
||||
type: {
|
||||
gte: permission,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (data > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the permission is read, we should check if the workspace is public
|
||||
if (permission === Permission.Read) {
|
||||
const data = await this.prisma.workspace.count({
|
||||
where: { id: ws, public: true },
|
||||
});
|
||||
|
||||
return data > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async grant(
|
||||
ws: string,
|
||||
user: string,
|
||||
permission: Permission = Permission.Read
|
||||
) {
|
||||
const data = await this.prisma.userWorkspacePermission.findFirst({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const [p] = await this.prisma.$transaction(
|
||||
[
|
||||
this.prisma.userWorkspacePermission.update({
|
||||
where: {
|
||||
id: data.id,
|
||||
},
|
||||
data: {
|
||||
type: permission,
|
||||
},
|
||||
}),
|
||||
|
||||
// If the new permission is owner, we need to revoke old owner
|
||||
permission === Permission.Owner
|
||||
? this.prisma.userWorkspacePermission.updateMany({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
type: Permission.Owner,
|
||||
userId: {
|
||||
not: user,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
type: Permission.Admin,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
].filter(Boolean) as Prisma.PrismaPromise<any>[]
|
||||
);
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
return this.prisma.userWorkspacePermission.create({
|
||||
data: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
type: permission,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async revoke(ws: string, user: string) {
|
||||
const result = await this.prisma.userWorkspacePermission.deleteMany({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
type: {
|
||||
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
|
||||
not: Permission.Owner,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result.count > 0;
|
||||
}
|
||||
}
|
@ -1,85 +1,206 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
ID,
|
||||
InputType,
|
||||
Int,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Parent,
|
||||
PartialType,
|
||||
PickType,
|
||||
Query,
|
||||
registerEnumType,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { workspaces } from '@prisma/client';
|
||||
import type { User, Workspace } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from '../../prisma/service';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { Auth, CurrentUser } from '../auth';
|
||||
import { UserType } from '../users/resolver';
|
||||
import { PermissionService } from './permission';
|
||||
import { Permission } from './types';
|
||||
|
||||
export enum WorkspaceType {
|
||||
Private = 0,
|
||||
Normal = 1,
|
||||
}
|
||||
|
||||
registerEnumType(WorkspaceType, {
|
||||
name: 'WorkspaceType',
|
||||
description: 'Workspace type',
|
||||
valuesMap: {
|
||||
Normal: {
|
||||
description: 'Normal workspace',
|
||||
},
|
||||
Private: {
|
||||
description: 'Private workspace',
|
||||
},
|
||||
},
|
||||
registerEnumType(Permission, {
|
||||
name: 'Permission',
|
||||
description: 'User permission in workspace',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class Workspace implements workspaces {
|
||||
export class WorkspaceType implements Partial<Workspace> {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field({ description: 'is Public workspace' })
|
||||
public!: boolean;
|
||||
@Field(() => WorkspaceType, { description: 'Workspace type' })
|
||||
type!: WorkspaceType;
|
||||
|
||||
@Field({ description: 'Workspace created date' })
|
||||
created_at!: Date;
|
||||
createdAt!: Date;
|
||||
}
|
||||
|
||||
@Resolver(() => Workspace)
|
||||
@InputType()
|
||||
export class UpdateWorkspaceInput extends PickType(
|
||||
PartialType(WorkspaceType),
|
||||
['public'],
|
||||
InputType
|
||||
) {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceResolver {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissionProvider: PermissionService
|
||||
) {}
|
||||
|
||||
// debug only query should be removed
|
||||
@Query(() => [Workspace], {
|
||||
name: 'workspaces',
|
||||
description: 'Get all workspaces',
|
||||
@ResolveField(() => Permission, {
|
||||
description: 'Permission of current signed in user in workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async workspaces() {
|
||||
return this.prisma.workspaces.findMany();
|
||||
async permission(
|
||||
@CurrentUser() user: User,
|
||||
@Parent() workspace: WorkspaceType
|
||||
) {
|
||||
// may applied in workspaces query
|
||||
if ('permission' in workspace) {
|
||||
return workspace.permission;
|
||||
}
|
||||
|
||||
const permission = this.permissionProvider.get(workspace.id, user.id);
|
||||
|
||||
if (!permission) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return permission;
|
||||
}
|
||||
|
||||
@Query(() => Workspace, {
|
||||
name: 'workspace',
|
||||
description: 'Get workspace by id',
|
||||
@ResolveField(() => Int, {
|
||||
description: 'member count of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async workspace(@Args('id') id: string) {
|
||||
return this.prisma.workspaces.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// create workspace
|
||||
@Mutation(() => Workspace, {
|
||||
name: 'createWorkspace',
|
||||
description: 'Create workspace',
|
||||
})
|
||||
async createWorkspace() {
|
||||
return this.prisma.workspaces.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
type: WorkspaceType.Private,
|
||||
public: false,
|
||||
created_at: new Date(),
|
||||
memberCount(@Parent() workspace: WorkspaceType) {
|
||||
return this.prisma.userWorkspacePermission.count({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => UserType, {
|
||||
description: 'Owner of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async owner(@Parent() workspace: WorkspaceType) {
|
||||
const data = await this.prisma.userWorkspacePermission.findFirstOrThrow({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
type: Permission.Owner,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
return data.user;
|
||||
}
|
||||
|
||||
@Query(() => [WorkspaceType], {
|
||||
description: 'Get all accessible workspaces for current user',
|
||||
complexity: 2,
|
||||
})
|
||||
async workspaces(@CurrentUser() user: User) {
|
||||
const data = await this.prisma.userWorkspacePermission.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
},
|
||||
include: {
|
||||
workspace: true,
|
||||
},
|
||||
});
|
||||
|
||||
return data.map(({ workspace, type }) => {
|
||||
return {
|
||||
...workspace,
|
||||
permission: type,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Query(() => WorkspaceType, {
|
||||
description: 'Get workspace by id',
|
||||
})
|
||||
async workspace(@CurrentUser() user: UserType, @Args('id') id: string) {
|
||||
await this.permissionProvider.check(id, user.id);
|
||||
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
|
||||
|
||||
if (!workspace) {
|
||||
throw new NotFoundException("Workspace doesn't exist");
|
||||
}
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
@Mutation(() => WorkspaceType, {
|
||||
description: 'Create a new workspace',
|
||||
})
|
||||
async createWorkspace(@CurrentUser() user: User) {
|
||||
return this.prisma.workspace.create({
|
||||
data: {
|
||||
public: false,
|
||||
users: {
|
||||
create: {
|
||||
type: Permission.Owner,
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => WorkspaceType, {
|
||||
description: 'Update workspace',
|
||||
})
|
||||
async updateWorkspace(
|
||||
@CurrentUser() user: User,
|
||||
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
|
||||
{ id, ...updates }: UpdateWorkspaceInput
|
||||
) {
|
||||
await this.permissionProvider.check('id', user.id, Permission.Admin);
|
||||
|
||||
return this.prisma.workspace.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: updates,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteWorkspace(@CurrentUser() user: User, @Args('id') id: string) {
|
||||
await this.permissionProvider.check(id, user.id, Permission.Owner);
|
||||
|
||||
await this.prisma.workspace.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO:
|
||||
// delete all related data, like websocket connections, blobs, etc.
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
6
apps/server/src/modules/workspaces/types.ts
Normal file
6
apps/server/src/modules/workspaces/types.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export enum Permission {
|
||||
Read = 0,
|
||||
Write = 1,
|
||||
Admin = 10,
|
||||
Owner = 99,
|
||||
}
|
@ -8,3 +8,4 @@ import { PrismaService } from './service';
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
export { PrismaService } from './service';
|
||||
|
@ -34,22 +34,46 @@ describe('AppModule', () => {
|
||||
.post(gql)
|
||||
.send({
|
||||
query: `
|
||||
query {
|
||||
error
|
||||
}
|
||||
`,
|
||||
query {
|
||||
error
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
let token;
|
||||
await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
signIn(email: "alex.yang@example.org", password: "123456") {
|
||||
token {
|
||||
token
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
ok(
|
||||
typeof res.body.data.signIn.token.token === 'string',
|
||||
'res.body.data.signIn.token.token is not a string'
|
||||
);
|
||||
token = res.body.data.signIn.token.token;
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.set({ Authorization: token })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
createWorkspace {
|
||||
id
|
||||
type
|
||||
public
|
||||
created_at
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
@ -64,16 +88,12 @@ describe('AppModule', () => {
|
||||
typeof res.body.data.createWorkspace.id === 'string',
|
||||
'res.body.data.createWorkspace.id is not a string'
|
||||
);
|
||||
ok(
|
||||
typeof res.body.data.createWorkspace.type === 'string',
|
||||
'res.body.data.createWorkspace.type is not a string'
|
||||
);
|
||||
ok(
|
||||
typeof res.body.data.createWorkspace.public === 'boolean',
|
||||
'res.body.data.createWorkspace.public is not a boolean'
|
||||
);
|
||||
ok(
|
||||
typeof res.body.data.createWorkspace.created_at === 'string',
|
||||
typeof res.body.data.createWorkspace.createdAt === 'string',
|
||||
'res.body.data.createWorkspace.created_at is not a string'
|
||||
);
|
||||
});
|
||||
@ -87,7 +107,7 @@ describe('AppModule', () => {
|
||||
query {
|
||||
user(email: "alex.yang@example.org") {
|
||||
email
|
||||
avatar_url
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
57
apps/server/src/utils/nestjs.ts
Normal file
57
apps/server/src/utils/nestjs.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { ArgumentsHost, ExecutionContext } from '@nestjs/common';
|
||||
import {
|
||||
GqlArgumentsHost,
|
||||
GqlContextType,
|
||||
GqlExecutionContext,
|
||||
} from '@nestjs/graphql';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
export function getRequestResponseFromContext(context: ExecutionContext) {
|
||||
switch (context.getType<GqlContextType>()) {
|
||||
case 'graphql': {
|
||||
const gqlContext = GqlExecutionContext.create(context).getContext<{
|
||||
req: Request;
|
||||
}>();
|
||||
return {
|
||||
req: gqlContext.req,
|
||||
res: gqlContext.req.res!,
|
||||
};
|
||||
}
|
||||
case 'http': {
|
||||
const http = context.switchToHttp();
|
||||
return {
|
||||
req: http.getRequest<Request>(),
|
||||
res: http.getResponse<Response>(),
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error('Unknown context type for getting request and response');
|
||||
}
|
||||
}
|
||||
|
||||
export function getRequestResponseFromHost(host: ArgumentsHost) {
|
||||
switch (host.getType<GqlContextType>()) {
|
||||
case 'graphql': {
|
||||
const gqlContext = GqlArgumentsHost.create(host).getContext<{
|
||||
req: Request;
|
||||
}>();
|
||||
return {
|
||||
req: gqlContext.req,
|
||||
res: gqlContext.req.res!,
|
||||
};
|
||||
}
|
||||
case 'http': {
|
||||
const http = host.switchToHttp();
|
||||
return {
|
||||
req: http.getRequest<Request>(),
|
||||
res: http.getResponse<Response>(),
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error('Unknown host type for getting request and response');
|
||||
}
|
||||
}
|
||||
|
||||
export function getRequestFromHost(host: ArgumentsHost) {
|
||||
return getRequestResponseFromHost(host).req;
|
||||
}
|
@ -7,5 +7,5 @@
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["./scripts", "package.json"]
|
||||
"include": ["scripts", "package.json"]
|
||||
}
|
||||
|
@ -46,6 +46,9 @@
|
||||
{
|
||||
"path": "./apps/web"
|
||||
},
|
||||
{
|
||||
"path": "./apps/server"
|
||||
},
|
||||
{
|
||||
"path": "./packages/component"
|
||||
},
|
||||
|
66
yarn.lock
66
yarn.lock
@ -248,13 +248,17 @@ __metadata:
|
||||
"@nestjs/platform-express": ^9.4.0
|
||||
"@nestjs/testing": ^9.4.0
|
||||
"@prisma/client": ^4.13.0
|
||||
"@types/express": ^4.17.17
|
||||
"@types/jsonwebtoken": ^9.0.1
|
||||
"@types/lodash-es": ^4.17.7
|
||||
"@types/node": ^18.16.2
|
||||
"@types/supertest": ^2.0.12
|
||||
c8: ^7.13.0
|
||||
dotenv: ^16.0.3
|
||||
express: ^4.18.2
|
||||
graphql: ^16.6.0
|
||||
graphql-type-json: ^0.3.2
|
||||
jsonwebtoken: ^9.0.0
|
||||
lodash-es: ^4.17.21
|
||||
nodemon: ^2.0.22
|
||||
prisma: ^4.13.0
|
||||
@ -8269,7 +8273,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/express@npm:^4.17.13, @types/express@npm:^4.7.0":
|
||||
"@types/express@npm:^4.17.13, @types/express@npm:^4.17.17, @types/express@npm:^4.7.0":
|
||||
version: 4.17.17
|
||||
resolution: "@types/express@npm:4.17.17"
|
||||
dependencies:
|
||||
@ -8438,6 +8442,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/jsonwebtoken@npm:^9.0.1":
|
||||
version: 9.0.1
|
||||
resolution: "@types/jsonwebtoken@npm:9.0.1"
|
||||
dependencies:
|
||||
"@types/node": "*"
|
||||
checksum: a7f0925e9a42ad3ae970364c63c5986d40da5c83d51d3f4e624eb0f064a380376f9e3fb3f2f837390a9ab80143f5d75fd51866da30e110f6b486a3379e1c768f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/keyv@npm:^3.1.4":
|
||||
version: 3.1.4
|
||||
resolution: "@types/keyv@npm:3.1.4"
|
||||
@ -10609,6 +10622,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"buffer-equal-constant-time@npm:1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "buffer-equal-constant-time@npm:1.0.1"
|
||||
checksum: 80bb945f5d782a56f374b292770901065bad21420e34936ecbe949e57724b4a13874f735850dd1cc61f078773c4fb5493a41391e7bda40d1fa388d6bd80daaab
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"buffer-equal@npm:^1.0.0":
|
||||
version: 1.0.1
|
||||
resolution: "buffer-equal@npm:1.0.1"
|
||||
@ -12575,6 +12595,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ecdsa-sig-formatter@npm:1.0.11":
|
||||
version: 1.0.11
|
||||
resolution: "ecdsa-sig-formatter@npm:1.0.11"
|
||||
dependencies:
|
||||
safe-buffer: ^5.0.1
|
||||
checksum: 207f9ab1c2669b8e65540bce29506134613dd5f122cccf1e6a560f4d63f2732d427d938f8481df175505aad94583bcb32c688737bb39a6df0625f903d6d93c03
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ee-first@npm:1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "ee-first@npm:1.1.1"
|
||||
@ -13749,7 +13778,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"express@npm:4.18.2, express@npm:^4.17.1, express@npm:^4.17.3":
|
||||
"express@npm:4.18.2, express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.2":
|
||||
version: 4.18.2
|
||||
resolution: "express@npm:4.18.2"
|
||||
dependencies:
|
||||
@ -17588,6 +17617,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsonwebtoken@npm:^9.0.0":
|
||||
version: 9.0.0
|
||||
resolution: "jsonwebtoken@npm:9.0.0"
|
||||
dependencies:
|
||||
jws: ^3.2.2
|
||||
lodash: ^4.17.21
|
||||
ms: ^2.1.1
|
||||
semver: ^7.3.8
|
||||
checksum: b9181cecf9df99f1dc0253f91ba000a1aa4d91f5816d1608c0dba61a5623726a0bfe200b51df25de18c1a6000825d231ad7ce2788aa54fd48dcb760ad9eb9514
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.3":
|
||||
version: 3.3.3
|
||||
resolution: "jsx-ast-utils@npm:3.3.3"
|
||||
@ -17605,6 +17646,27 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jwa@npm:^1.4.1":
|
||||
version: 1.4.1
|
||||
resolution: "jwa@npm:1.4.1"
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: ^5.0.1
|
||||
checksum: ff30ea7c2dcc61f3ed2098d868bf89d43701605090c5b21b5544b512843ec6fd9e028381a4dda466cbcdb885c2d1150f7c62e7168394ee07941b4098e1035e2f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jws@npm:^3.2.2":
|
||||
version: 3.2.2
|
||||
resolution: "jws@npm:3.2.2"
|
||||
dependencies:
|
||||
jwa: ^1.4.1
|
||||
safe-buffer: ^5.0.1
|
||||
checksum: f0213fe5b79344c56cd443428d8f65c16bf842dc8cb8f5aed693e1e91d79c20741663ad6eff07a6d2c433d1831acc9814e8d7bada6a0471fbb91d09ceb2bf5c2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"keyv@npm:^4.0.0, keyv@npm:^4.5.2":
|
||||
version: 4.5.2
|
||||
resolution: "keyv@npm:4.5.2"
|
||||
|
Loading…
Reference in New Issue
Block a user