feat(server): introduce user friendly server errors (#7111)
This commit is contained in:
@@ -131,6 +131,11 @@
|
|||||||
"ts-node/esm/transpile-only.mjs",
|
"ts-node/esm/transpile-only.mjs",
|
||||||
"--es-module-specifier-resolution=node"
|
"--es-module-specifier-resolution=node"
|
||||||
],
|
],
|
||||||
|
"watchMode": {
|
||||||
|
"ignoreChanges": [
|
||||||
|
"**/*.gen.*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"tests/**/*.spec.ts",
|
"tests/**/*.spec.ts",
|
||||||
"tests/**/*.e2e.ts"
|
"tests/**/*.e2e.ts"
|
||||||
@@ -160,7 +165,8 @@
|
|||||||
],
|
],
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"**/__tests__/**",
|
"**/__tests__/**",
|
||||||
"**/dist/**"
|
"**/dist/**",
|
||||||
|
"*.gen.*"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"TS_NODE_TRANSPILE_ONLY": true,
|
"TS_NODE_TRANSPILE_ONLY": true,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
ConfigModule,
|
ConfigModule,
|
||||||
mergeConfigOverride,
|
mergeConfigOverride,
|
||||||
} from './fundamentals/config';
|
} from './fundamentals/config';
|
||||||
|
import { ErrorModule } from './fundamentals/error';
|
||||||
import { EventModule } from './fundamentals/event';
|
import { EventModule } from './fundamentals/event';
|
||||||
import { GqlModule } from './fundamentals/graphql';
|
import { GqlModule } from './fundamentals/graphql';
|
||||||
import { HelpersModule } from './fundamentals/helpers';
|
import { HelpersModule } from './fundamentals/helpers';
|
||||||
@@ -52,6 +53,7 @@ export const FunctionalityModules = [
|
|||||||
MailModule,
|
MailModule,
|
||||||
StorageProviderModule,
|
StorageProviderModule,
|
||||||
HelpersModule,
|
HelpersModule,
|
||||||
|
ErrorModule,
|
||||||
];
|
];
|
||||||
|
|
||||||
function filterOptionalModule(
|
function filterOptionalModule(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@@ -14,7 +13,16 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
import { Config, Throttle, URLHelper } from '../../fundamentals';
|
import {
|
||||||
|
Config,
|
||||||
|
EarlyAccessRequired,
|
||||||
|
EmailTokenNotFound,
|
||||||
|
InternalServerError,
|
||||||
|
InvalidEmailToken,
|
||||||
|
SignUpForbidden,
|
||||||
|
Throttle,
|
||||||
|
URLHelper,
|
||||||
|
} from '../../fundamentals';
|
||||||
import { UserService } from '../user';
|
import { UserService } from '../user';
|
||||||
import { validators } from '../utils/validators';
|
import { validators } from '../utils/validators';
|
||||||
import { CurrentUser } from './current-user';
|
import { CurrentUser } from './current-user';
|
||||||
@@ -55,9 +63,7 @@ export class AuthController {
|
|||||||
validators.assertValidEmail(credential.email);
|
validators.assertValidEmail(credential.email);
|
||||||
const canSignIn = await this.auth.canSignIn(credential.email);
|
const canSignIn = await this.auth.canSignIn(credential.email);
|
||||||
if (!canSignIn) {
|
if (!canSignIn) {
|
||||||
throw new BadRequestException(
|
throw new EarlyAccessRequired();
|
||||||
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (credential.password) {
|
if (credential.password) {
|
||||||
@@ -74,7 +80,7 @@ export class AuthController {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
const allowSignup = await this.config.runtime.fetch('auth/allowSignup');
|
const allowSignup = await this.config.runtime.fetch('auth/allowSignup');
|
||||||
if (!allowSignup) {
|
if (!allowSignup) {
|
||||||
throw new BadRequestException('You are not allows to sign up.');
|
throw new SignUpForbidden();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +90,7 @@ export class AuthController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.rejected.length) {
|
if (result.rejected.length) {
|
||||||
throw new Error('Failed to send sign-in email.');
|
throw new InternalServerError('Failed to send sign-in email.');
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(HttpStatus.OK).send({
|
res.status(HttpStatus.OK).send({
|
||||||
@@ -145,7 +151,7 @@ export class AuthController {
|
|||||||
@Body() { email, token }: MagicLinkCredential
|
@Body() { email, token }: MagicLinkCredential
|
||||||
) {
|
) {
|
||||||
if (!token || !email) {
|
if (!token || !email) {
|
||||||
throw new BadRequestException('Missing sign-in mail token');
|
throw new EmailTokenNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
validators.assertValidEmail(email);
|
validators.assertValidEmail(email);
|
||||||
@@ -155,7 +161,7 @@ export class AuthController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new BadRequestException('Invalid sign-in mail token');
|
throw new InvalidEmailToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.user.fulfillUser(email, {
|
const user = await this.user.fulfillUser(email, {
|
||||||
|
|||||||
@@ -3,15 +3,13 @@ import type {
|
|||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
OnModuleInit,
|
OnModuleInit,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import { Injectable, SetMetadata, UseGuards } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
SetMetadata,
|
|
||||||
UnauthorizedException,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ModuleRef, Reflector } from '@nestjs/core';
|
import { ModuleRef, Reflector } from '@nestjs/core';
|
||||||
|
|
||||||
import { getRequestResponseFromContext } from '../../fundamentals';
|
import {
|
||||||
|
AuthenticationRequired,
|
||||||
|
getRequestResponseFromContext,
|
||||||
|
} from '../../fundamentals';
|
||||||
import { AuthService, parseAuthUserSeqNum } from './service';
|
import { AuthService, parseAuthUserSeqNum } from './service';
|
||||||
|
|
||||||
function extractTokenFromHeader(authorization: string) {
|
function extractTokenFromHeader(authorization: string) {
|
||||||
@@ -84,7 +82,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
throw new UnauthorizedException('You are not signed in.');
|
throw new AuthenticationRequired();
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Field,
|
Field,
|
||||||
@@ -10,7 +9,18 @@ import {
|
|||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
|
|
||||||
import { Config, SkipThrottle, Throttle, URLHelper } from '../../fundamentals';
|
import {
|
||||||
|
ActionForbidden,
|
||||||
|
Config,
|
||||||
|
EmailAlreadyUsed,
|
||||||
|
EmailTokenNotFound,
|
||||||
|
EmailVerificationRequired,
|
||||||
|
InvalidEmailToken,
|
||||||
|
SameEmailProvided,
|
||||||
|
SkipThrottle,
|
||||||
|
Throttle,
|
||||||
|
URLHelper,
|
||||||
|
} from '../../fundamentals';
|
||||||
import { UserService } from '../user';
|
import { UserService } from '../user';
|
||||||
import { UserType } from '../user/types';
|
import { UserType } from '../user/types';
|
||||||
import { validators } from '../utils/validators';
|
import { validators } from '../utils/validators';
|
||||||
@@ -62,7 +72,7 @@ export class AuthResolver {
|
|||||||
@Parent() user: UserType
|
@Parent() user: UserType
|
||||||
): Promise<ClientTokenType> {
|
): Promise<ClientTokenType> {
|
||||||
if (user.id !== currentUser.id) {
|
if (user.id !== currentUser.id) {
|
||||||
throw new ForbiddenException('Invalid user');
|
throw new ActionForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await this.auth.createUserSession(
|
const session = await this.auth.createUserSession(
|
||||||
@@ -102,7 +112,7 @@ export class AuthResolver {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new ForbiddenException('Invalid token');
|
throw new InvalidEmailToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.auth.changePassword(user.id, newPassword);
|
await this.auth.changePassword(user.id, newPassword);
|
||||||
@@ -124,7 +134,7 @@ export class AuthResolver {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new ForbiddenException('Invalid token');
|
throw new InvalidEmailToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
email = decodeURIComponent(email);
|
email = decodeURIComponent(email);
|
||||||
@@ -144,7 +154,7 @@ export class AuthResolver {
|
|||||||
@Args('email', { nullable: true }) _email?: string
|
@Args('email', { nullable: true }) _email?: string
|
||||||
) {
|
) {
|
||||||
if (!user.emailVerified) {
|
if (!user.emailVerified) {
|
||||||
throw new ForbiddenException('Please verify your email first.');
|
throw new EmailVerificationRequired();
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await this.token.createToken(
|
const token = await this.token.createToken(
|
||||||
@@ -166,7 +176,7 @@ export class AuthResolver {
|
|||||||
@Args('email', { nullable: true }) _email?: string
|
@Args('email', { nullable: true }) _email?: string
|
||||||
) {
|
) {
|
||||||
if (!user.emailVerified) {
|
if (!user.emailVerified) {
|
||||||
throw new ForbiddenException('Please verify your email first.');
|
throw new EmailVerificationRequired();
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await this.token.createToken(
|
const token = await this.token.createToken(
|
||||||
@@ -195,7 +205,7 @@ export class AuthResolver {
|
|||||||
@Args('email', { nullable: true }) _email?: string
|
@Args('email', { nullable: true }) _email?: string
|
||||||
) {
|
) {
|
||||||
if (!user.emailVerified) {
|
if (!user.emailVerified) {
|
||||||
throw new ForbiddenException('Please verify your email first.');
|
throw new EmailVerificationRequired();
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await this.token.createToken(TokenType.ChangeEmail, user.id);
|
const token = await this.token.createToken(TokenType.ChangeEmail, user.id);
|
||||||
@@ -213,24 +223,26 @@ export class AuthResolver {
|
|||||||
@Args('email') email: string,
|
@Args('email') email: string,
|
||||||
@Args('callbackUrl') callbackUrl: string
|
@Args('callbackUrl') callbackUrl: string
|
||||||
) {
|
) {
|
||||||
|
if (!token) {
|
||||||
|
throw new EmailTokenNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
validators.assertValidEmail(email);
|
validators.assertValidEmail(email);
|
||||||
const valid = await this.token.verifyToken(TokenType.ChangeEmail, token, {
|
const valid = await this.token.verifyToken(TokenType.ChangeEmail, token, {
|
||||||
credential: user.id,
|
credential: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new ForbiddenException('Invalid token');
|
throw new InvalidEmailToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasRegistered = await this.user.findUserByEmail(email);
|
const hasRegistered = await this.user.findUserByEmail(email);
|
||||||
|
|
||||||
if (hasRegistered) {
|
if (hasRegistered) {
|
||||||
if (hasRegistered.id !== user.id) {
|
if (hasRegistered.id !== user.id) {
|
||||||
throw new BadRequestException(`The email provided has been taken.`);
|
throw new EmailAlreadyUsed();
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestException(
|
throw new SameEmailProvided();
|
||||||
`The email provided is the same as the current email.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +276,7 @@ export class AuthResolver {
|
|||||||
@Args('token') token: string
|
@Args('token') token: string
|
||||||
) {
|
) {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new BadRequestException('Invalid token');
|
throw new EmailTokenNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, {
|
const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, {
|
||||||
@@ -272,7 +284,7 @@ export class AuthResolver {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new ForbiddenException('Invalid token');
|
throw new InvalidEmailToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { emailVerifiedAt } = await this.auth.setEmailVerified(user.id);
|
const { emailVerifiedAt } = await this.auth.setEmailVerified(user.id);
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import {
|
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
NotAcceptableException,
|
|
||||||
OnApplicationBootstrap,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import type { CookieOptions, Request, Response } from 'express';
|
import type { CookieOptions, Request, Response } from 'express';
|
||||||
import { assign, omit } from 'lodash-es';
|
import { assign, omit } from 'lodash-es';
|
||||||
|
|
||||||
import { Config, CryptoHelper, MailService } from '../../fundamentals';
|
import {
|
||||||
|
Config,
|
||||||
|
CryptoHelper,
|
||||||
|
EmailAlreadyUsed,
|
||||||
|
MailService,
|
||||||
|
WrongSignInCredentials,
|
||||||
|
WrongSignInMethod,
|
||||||
|
} from '../../fundamentals';
|
||||||
import { FeatureManagementService } from '../features/management';
|
import { FeatureManagementService } from '../features/management';
|
||||||
import { QuotaService } from '../quota/service';
|
import { QuotaService } from '../quota/service';
|
||||||
import { QuotaType } from '../quota/types';
|
import { QuotaType } from '../quota/types';
|
||||||
@@ -109,7 +111,7 @@ export class AuthService implements OnApplicationBootstrap {
|
|||||||
const user = await this.user.findUserByEmail(email);
|
const user = await this.user.findUserByEmail(email);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
throw new BadRequestException('Email was taken');
|
throw new EmailAlreadyUsed();
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await this.crypto.encryptPassword(password);
|
const hashedPassword = await this.crypto.encryptPassword(password);
|
||||||
@@ -127,13 +129,11 @@ export class AuthService implements OnApplicationBootstrap {
|
|||||||
const user = await this.user.findUserWithHashedPasswordByEmail(email);
|
const user = await this.user.findUserWithHashedPasswordByEmail(email);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotAcceptableException('Invalid sign in credentials');
|
throw new WrongSignInCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.password) {
|
if (!user.password) {
|
||||||
throw new NotAcceptableException(
|
throw new WrongSignInMethod();
|
||||||
'User Password is not set. Should login through email link.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordMatches = await this.crypto.verifyPassword(
|
const passwordMatches = await this.crypto.verifyPassword(
|
||||||
@@ -142,7 +142,7 @@ export class AuthService implements OnApplicationBootstrap {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!passwordMatches) {
|
if (!passwordMatches) {
|
||||||
throw new NotAcceptableException('Invalid sign in credentials');
|
throw new WrongSignInCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessionUser(user);
|
return sessionUser(user);
|
||||||
@@ -382,27 +382,14 @@ export class AuthService implements OnApplicationBootstrap {
|
|||||||
id: string,
|
id: string,
|
||||||
newPassword: string
|
newPassword: string
|
||||||
): Promise<Omit<User, 'password'>> {
|
): Promise<Omit<User, 'password'>> {
|
||||||
const user = await this.user.findUserById(id);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new BadRequestException('Invalid email');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashedPassword = await this.crypto.encryptPassword(newPassword);
|
const hashedPassword = await this.crypto.encryptPassword(newPassword);
|
||||||
|
return this.user.updateUser(id, { password: hashedPassword });
|
||||||
return this.user.updateUser(user.id, { password: hashedPassword });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async changeEmail(
|
async changeEmail(
|
||||||
id: string,
|
id: string,
|
||||||
newEmail: string
|
newEmail: string
|
||||||
): Promise<Omit<User, 'password'>> {
|
): Promise<Omit<User, 'password'>> {
|
||||||
const user = await this.user.findUserById(id);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new BadRequestException('Invalid email');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.user.updateUser(id, {
|
return this.user.updateUser(id, {
|
||||||
email: newEmail,
|
email: newEmail,
|
||||||
emailVerifiedAt: new Date(),
|
emailVerifiedAt: new Date(),
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import type {
|
|||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
OnModuleInit,
|
OnModuleInit,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Injectable, UnauthorizedException, UseGuards } from '@nestjs/common';
|
import { Injectable, UseGuards } from '@nestjs/common';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
|
|
||||||
import { getRequestResponseFromContext } from '../../fundamentals';
|
import {
|
||||||
|
ActionForbidden,
|
||||||
|
getRequestResponseFromContext,
|
||||||
|
} from '../../fundamentals';
|
||||||
import { FeatureManagementService } from '../features';
|
import { FeatureManagementService } from '../features';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -27,7 +30,7 @@ export class AdminGuard implements CanActivate, OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!allow) {
|
if (!allow) {
|
||||||
throw new UnauthorizedException('Your operation is not allowed.');
|
throw new ActionForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
import type { EventPayload } from '../../fundamentals';
|
import type { EventPayload } from '../../fundamentals';
|
||||||
import { Config, metrics, OnEvent } from '../../fundamentals';
|
import {
|
||||||
|
Config,
|
||||||
|
DocHistoryNotFound,
|
||||||
|
DocNotFound,
|
||||||
|
metrics,
|
||||||
|
OnEvent,
|
||||||
|
WorkspaceNotFound,
|
||||||
|
} from '../../fundamentals';
|
||||||
import { QuotaService } from '../quota';
|
import { QuotaService } from '../quota';
|
||||||
import { Permission } from '../workspaces/types';
|
import { Permission } from '../workspaces/types';
|
||||||
import { isEmptyBuffer } from './manager';
|
import { isEmptyBuffer } from './manager';
|
||||||
@@ -191,7 +198,11 @@ export class DocHistoryManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!history) {
|
if (!history) {
|
||||||
throw new Error('Given history not found');
|
throw new DocHistoryNotFound({
|
||||||
|
workspaceId,
|
||||||
|
docId: id,
|
||||||
|
timestamp: timestamp.getTime(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldSnapshot = await this.db.snapshot.findUnique({
|
const oldSnapshot = await this.db.snapshot.findUnique({
|
||||||
@@ -204,8 +215,7 @@ export class DocHistoryManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!oldSnapshot) {
|
if (!oldSnapshot) {
|
||||||
// unreachable actually
|
throw new DocNotFound({ workspaceId, docId: id });
|
||||||
throw new Error('Given Doc not found');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// save old snapshot as one history record
|
// save old snapshot as one history record
|
||||||
@@ -236,8 +246,7 @@ export class DocHistoryManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!permission) {
|
if (!permission) {
|
||||||
// unreachable actually
|
throw new WorkspaceNotFound({ workspaceId });
|
||||||
throw new Error('Workspace owner not found');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const quota = await this.quota.getUserQuota(permission.userId);
|
const quota = await this.quota.getUserQuota(permission.userId);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Context,
|
Context,
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { UserNotFound } from '../../fundamentals';
|
||||||
import { sessionUser } from '../auth/service';
|
import { sessionUser } from '../auth/service';
|
||||||
import { Admin } from '../common';
|
import { Admin } from '../common';
|
||||||
import { UserService } from '../user/service';
|
import { UserService } from '../user/service';
|
||||||
@@ -59,7 +59,7 @@ export class FeatureManagementResolver {
|
|||||||
async removeEarlyAccess(@Args('email') email: string): Promise<number> {
|
async removeEarlyAccess(@Args('email') email: string): Promise<number> {
|
||||||
const user = await this.users.findUserByEmail(email);
|
const user = await this.users.findUserByEmail(email);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new BadRequestException(`User ${email} not found`);
|
throw new UserNotFound();
|
||||||
}
|
}
|
||||||
return this.feature.removeEarlyAccess(user.id);
|
return this.feature.removeEarlyAccess(user.id);
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,7 @@ export class FeatureManagementResolver {
|
|||||||
const user = await this.users.findUserByEmail(email);
|
const user = await this.users.findUserByEmail(email);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new BadRequestException(`User ${email} not found`);
|
throw new UserNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.feature.addAdmin(user.id);
|
await this.feature.addAdmin(user.id);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { WorkspaceOwnerNotFound } from '../../fundamentals';
|
||||||
import { FeatureService, FeatureType } from '../features';
|
import { FeatureService, FeatureType } from '../features';
|
||||||
import { WorkspaceBlobStorage } from '../storage';
|
import { WorkspaceBlobStorage } from '../storage';
|
||||||
import { PermissionService } from '../workspaces/permission';
|
import { PermissionService } from '../workspaces/permission';
|
||||||
@@ -115,7 +116,7 @@ export class QuotaManagementService {
|
|||||||
async getWorkspaceUsage(workspaceId: string): Promise<QuotaBusinessType> {
|
async getWorkspaceUsage(workspaceId: string): Promise<QuotaBusinessType> {
|
||||||
const { user: owner } =
|
const { user: owner } =
|
||||||
await this.permissions.getWorkspaceOwner(workspaceId);
|
await this.permissions.getWorkspaceOwner(workspaceId);
|
||||||
if (!owner) throw new NotFoundException('Workspace owner not found');
|
if (!owner) throw new WorkspaceOwnerNotFound({ workspaceId });
|
||||||
const {
|
const {
|
||||||
feature: {
|
feature: {
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
export enum EventErrorCode {
|
|
||||||
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
|
||||||
DOC_NOT_FOUND = 'DOC_NOT_FOUND',
|
|
||||||
NOT_IN_WORKSPACE = 'NOT_IN_WORKSPACE',
|
|
||||||
ACCESS_DENIED = 'ACCESS_DENIED',
|
|
||||||
INTERNAL = 'INTERNAL',
|
|
||||||
VERSION_REJECTED = 'VERSION_REJECTED',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Such errore are generally raised from the gateway handling to user,
|
|
||||||
// the stack must be full of internal code,
|
|
||||||
// so there is no need to inherit from `Error` class.
|
|
||||||
export class EventError {
|
|
||||||
constructor(
|
|
||||||
public readonly code: EventErrorCode,
|
|
||||||
public readonly message: string
|
|
||||||
) {}
|
|
||||||
|
|
||||||
toJSON() {
|
|
||||||
return {
|
|
||||||
code: this.code,
|
|
||||||
message: this.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class WorkspaceNotFoundError extends EventError {
|
|
||||||
constructor(public readonly workspaceId: string) {
|
|
||||||
super(
|
|
||||||
EventErrorCode.WORKSPACE_NOT_FOUND,
|
|
||||||
`You are trying to access an unknown workspace ${workspaceId}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DocNotFoundError extends EventError {
|
|
||||||
constructor(
|
|
||||||
public readonly workspaceId: string,
|
|
||||||
public readonly docId: string
|
|
||||||
) {
|
|
||||||
super(
|
|
||||||
EventErrorCode.DOC_NOT_FOUND,
|
|
||||||
`You are trying to access an unknown doc ${docId} under workspace ${workspaceId}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NotInWorkspaceError extends EventError {
|
|
||||||
constructor(public readonly workspaceId: string) {
|
|
||||||
super(
|
|
||||||
EventErrorCode.NOT_IN_WORKSPACE,
|
|
||||||
`You should join in workspace ${workspaceId} before broadcasting messages.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AccessDeniedError extends EventError {
|
|
||||||
constructor(public readonly workspaceId: string) {
|
|
||||||
super(
|
|
||||||
EventErrorCode.ACCESS_DENIED,
|
|
||||||
`You have no permission to access workspace ${workspaceId}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InternalError extends EventError {
|
|
||||||
constructor(public readonly error: Error) {
|
|
||||||
super(EventErrorCode.INTERNAL, `Internal error happened: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class VersionRejectedError extends EventError {
|
|
||||||
constructor(public readonly version: number) {
|
|
||||||
super(
|
|
||||||
EventErrorCode.VERSION_REJECTED,
|
|
||||||
// TODO: Too general error message,
|
|
||||||
// need to be more specific when versioning system is implemented.
|
|
||||||
`The version ${version} is rejected by server.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,73 +11,36 @@ import {
|
|||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
|
import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
|
||||||
|
|
||||||
import { CallTimer, Config, metrics } from '../../../fundamentals';
|
import {
|
||||||
|
CallTimer,
|
||||||
|
Config,
|
||||||
|
DocNotFound,
|
||||||
|
GatewayErrorWrapper,
|
||||||
|
metrics,
|
||||||
|
NotInWorkspace,
|
||||||
|
VersionRejected,
|
||||||
|
WorkspaceAccessDenied,
|
||||||
|
} from '../../../fundamentals';
|
||||||
import { Auth, CurrentUser } from '../../auth';
|
import { Auth, CurrentUser } from '../../auth';
|
||||||
import { DocManager } from '../../doc';
|
import { DocManager } from '../../doc';
|
||||||
import { DocID } from '../../utils/doc';
|
import { DocID } from '../../utils/doc';
|
||||||
import { PermissionService } from '../../workspaces/permission';
|
import { PermissionService } from '../../workspaces/permission';
|
||||||
import { Permission } from '../../workspaces/types';
|
import { Permission } from '../../workspaces/types';
|
||||||
import {
|
|
||||||
AccessDeniedError,
|
|
||||||
DocNotFoundError,
|
|
||||||
EventError,
|
|
||||||
EventErrorCode,
|
|
||||||
InternalError,
|
|
||||||
NotInWorkspaceError,
|
|
||||||
} from './error';
|
|
||||||
|
|
||||||
export const GatewayErrorWrapper = (): MethodDecorator => {
|
|
||||||
// @ts-expect-error allow
|
|
||||||
return (
|
|
||||||
_target,
|
|
||||||
_key,
|
|
||||||
desc: TypedPropertyDescriptor<(...args: any[]) => any>
|
|
||||||
) => {
|
|
||||||
const originalMethod = desc.value;
|
|
||||||
if (!originalMethod) {
|
|
||||||
return desc;
|
|
||||||
}
|
|
||||||
|
|
||||||
desc.value = async function (...args: any[]) {
|
|
||||||
try {
|
|
||||||
return await originalMethod.apply(this, args);
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof EventError) {
|
|
||||||
return {
|
|
||||||
error: e,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
metrics.socketio.counter('unhandled_errors').add(1);
|
|
||||||
new Logger('EventsGateway').error(e, (e as Error).stack);
|
|
||||||
return {
|
|
||||||
error: new InternalError(e as Error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return desc;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const SubscribeMessage = (event: string) =>
|
const SubscribeMessage = (event: string) =>
|
||||||
applyDecorators(
|
applyDecorators(
|
||||||
GatewayErrorWrapper(),
|
GatewayErrorWrapper(event),
|
||||||
CallTimer('socketio', 'event_duration', { event }),
|
CallTimer('socketio', 'event_duration', { event }),
|
||||||
RawSubscribeMessage(event)
|
RawSubscribeMessage(event)
|
||||||
);
|
);
|
||||||
|
|
||||||
type EventResponse<Data = any> =
|
type EventResponse<Data = any> = Data extends never
|
||||||
| {
|
|
||||||
error: EventError;
|
|
||||||
}
|
|
||||||
| (Data extends never
|
|
||||||
? {
|
? {
|
||||||
data?: never;
|
data?: never;
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
data: Data;
|
data: Data;
|
||||||
});
|
};
|
||||||
|
|
||||||
function Sync(workspaceId: string): `${string}:sync` {
|
function Sync(workspaceId: string): `${string}:sync` {
|
||||||
return `${workspaceId}:sync`;
|
return `${workspaceId}:sync`;
|
||||||
@@ -133,10 +96,10 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
} is outdated, please update to ${AFFiNE.version}`,
|
} is outdated, please update to ${AFFiNE.version}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new EventError(
|
throw new VersionRejected({
|
||||||
EventErrorCode.VERSION_REJECTED,
|
version: version || 'unknown',
|
||||||
`Client version ${version} is outdated, please update to ${AFFiNE.version}`
|
serverVersion: AFFiNE.version,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +119,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
|
|
||||||
assertInWorkspace(client: Socket, room: `${string}:${'sync' | 'awareness'}`) {
|
assertInWorkspace(client: Socket, room: `${string}:${'sync' | 'awareness'}`) {
|
||||||
if (!client.rooms.has(room)) {
|
if (!client.rooms.has(room)) {
|
||||||
throw new NotInWorkspaceError(room);
|
throw new NotInWorkspace({ workspaceId: room.split(':')[0] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +135,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
permission
|
permission
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
throw new AccessDeniedError(workspaceId);
|
throw new WorkspaceAccessDenied({ workspaceId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,9 +281,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
const res = await this.docManager.get(docId.workspace, docId.guid);
|
const res = await this.docManager.get(docId.workspace, docId.guid);
|
||||||
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
return {
|
throw new DocNotFound({ workspaceId, docId: docId.guid });
|
||||||
error: new DocNotFoundError(workspaceId, docId.guid),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const missing = Buffer.from(
|
const missing = Buffer.from(
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import {
|
import { Controller, Get, Param, Res } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
ForbiddenException,
|
|
||||||
Get,
|
|
||||||
NotFoundException,
|
|
||||||
Param,
|
|
||||||
Res,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
|
import { ActionForbidden, UserAvatarNotFound } from '../../fundamentals';
|
||||||
import { AvatarStorage } from '../storage';
|
import { AvatarStorage } from '../storage';
|
||||||
|
|
||||||
@Controller('/api/avatars')
|
@Controller('/api/avatars')
|
||||||
@@ -17,7 +11,7 @@ export class UserAvatarController {
|
|||||||
@Get('/:id')
|
@Get('/:id')
|
||||||
async getAvatar(@Res() res: Response, @Param('id') id: string) {
|
async getAvatar(@Res() res: Response, @Param('id') id: string) {
|
||||||
if (this.storage.provider.type !== 'fs') {
|
if (this.storage.provider.type !== 'fs') {
|
||||||
throw new ForbiddenException(
|
throw new ActionForbidden(
|
||||||
'Only available when avatar storage provider set to fs.'
|
'Only available when avatar storage provider set to fs.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -25,7 +19,7 @@ export class UserAvatarController {
|
|||||||
const { body, metadata } = await this.storage.get(id);
|
const { body, metadata } = await this.storage.get(id);
|
||||||
|
|
||||||
if (!body) {
|
if (!body) {
|
||||||
throw new NotFoundException(`Avatar ${id} not found.`);
|
throw new UserAvatarNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
// metadata should always exists if body is not null
|
// metadata should always exists if body is not null
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Field,
|
Field,
|
||||||
@@ -18,6 +17,7 @@ import {
|
|||||||
CryptoHelper,
|
CryptoHelper,
|
||||||
type FileUpload,
|
type FileUpload,
|
||||||
Throttle,
|
Throttle,
|
||||||
|
UserNotFound,
|
||||||
} from '../../fundamentals';
|
} from '../../fundamentals';
|
||||||
import { CurrentUser } from '../auth/current-user';
|
import { CurrentUser } from '../auth/current-user';
|
||||||
import { Public } from '../auth/guard';
|
import { Public } from '../auth/guard';
|
||||||
@@ -92,7 +92,7 @@ export class UserResolver {
|
|||||||
avatar: FileUpload
|
avatar: FileUpload
|
||||||
) {
|
) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new BadRequestException(`User not found`);
|
throw new UserNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const avatarUrl = await this.storage.put(
|
const avatarUrl = await this.storage.put(
|
||||||
@@ -128,7 +128,7 @@ export class UserResolver {
|
|||||||
})
|
})
|
||||||
async removeAvatar(@CurrentUser() user: CurrentUser) {
|
async removeAvatar(@CurrentUser() user: CurrentUser) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new BadRequestException(`User not found`);
|
throw new UserNotFound();
|
||||||
}
|
}
|
||||||
await this.users.updateUser(user.id, { avatarUrl: null });
|
await this.users.updateUser(user.id, { avatarUrl: null });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Prisma, PrismaClient } from '@prisma/client';
|
import { Prisma, PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Config,
|
Config,
|
||||||
|
EmailAlreadyUsed,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
type EventPayload,
|
type EventPayload,
|
||||||
OnEvent,
|
OnEvent,
|
||||||
@@ -63,7 +64,7 @@ export class UserService {
|
|||||||
const user = await this.findUserByEmail(email);
|
const user = await this.findUserByEmail(email);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
throw new BadRequestException('Email already exists');
|
throw new EmailAlreadyUsed();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.createUser({
|
return this.createUser({
|
||||||
|
|||||||
@@ -1,36 +1,23 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
function assertValid<T>(z: z.ZodType<T>, value: unknown) {
|
import { InvalidEmail, InvalidPasswordLength } from '../../fundamentals';
|
||||||
const result = z.safeParse(value);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
const firstIssue = result.error.issues.at(0);
|
|
||||||
if (firstIssue) {
|
|
||||||
throw new BadRequestException(firstIssue.message);
|
|
||||||
} else {
|
|
||||||
throw new BadRequestException('Invalid credential');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertValidEmail(email: string) {
|
export function assertValidEmail(email: string) {
|
||||||
assertValid(z.string().email({ message: 'Invalid email address' }), email);
|
const result = z.string().email().safeParse(email);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new InvalidEmail();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertValidPassword(
|
export function assertValidPassword(
|
||||||
password: string,
|
password: string,
|
||||||
{ min, max }: { min: number; max: number }
|
{ min, max }: { min: number; max: number }
|
||||||
) {
|
) {
|
||||||
assertValid(
|
const result = z.string().min(min).max(max).safeParse(password);
|
||||||
z
|
|
||||||
.string()
|
if (!result.success) {
|
||||||
.min(min, { message: `Password must be ${min} or more charactors long` })
|
throw new InvalidPasswordLength({ min, max });
|
||||||
.max(max, {
|
}
|
||||||
message: `Password must be ${max} or fewer charactors long`,
|
|
||||||
}),
|
|
||||||
password
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const validators = {
|
export const validators = {
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import {
|
import { Controller, Get, Logger, Param, Res } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
ForbiddenException,
|
|
||||||
Get,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
Param,
|
|
||||||
Res,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import { CallTimer } from '../../fundamentals';
|
import {
|
||||||
|
AccessDenied,
|
||||||
|
ActionForbidden,
|
||||||
|
BlobNotFound,
|
||||||
|
CallTimer,
|
||||||
|
DocHistoryNotFound,
|
||||||
|
DocNotFound,
|
||||||
|
InvalidHistoryTimestamp,
|
||||||
|
} from '../../fundamentals';
|
||||||
import { CurrentUser, Public } from '../auth';
|
import { CurrentUser, Public } from '../auth';
|
||||||
import { DocHistoryManager, DocManager } from '../doc';
|
import { DocHistoryManager, DocManager } from '../doc';
|
||||||
import { WorkspaceBlobStorage } from '../storage';
|
import { WorkspaceBlobStorage } from '../storage';
|
||||||
@@ -50,15 +50,16 @@ export class WorkspacesController {
|
|||||||
user?.id
|
user?.id
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException('Permission denied');
|
throw new ActionForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { body, metadata } = await this.storage.get(workspaceId, name);
|
const { body, metadata } = await this.storage.get(workspaceId, name);
|
||||||
|
|
||||||
if (!body) {
|
if (!body) {
|
||||||
throw new NotFoundException(
|
throw new BlobNotFound({
|
||||||
`Blob not found in workspace ${workspaceId}: ${name}`
|
workspaceId,
|
||||||
);
|
blobId: name,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// metadata should always exists if body is not null
|
// metadata should always exists if body is not null
|
||||||
@@ -93,7 +94,7 @@ export class WorkspacesController {
|
|||||||
user?.id
|
user?.id
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException('Permission denied');
|
throw new AccessDenied();
|
||||||
}
|
}
|
||||||
|
|
||||||
const binResponse = await this.docManager.getBinary(
|
const binResponse = await this.docManager.getBinary(
|
||||||
@@ -102,7 +103,10 @@ export class WorkspacesController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!binResponse) {
|
if (!binResponse) {
|
||||||
throw new NotFoundException('Doc not found');
|
throw new DocNotFound({
|
||||||
|
workspaceId: docId.workspace,
|
||||||
|
docId: docId.guid,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!docId.isWorkspace) {
|
if (!docId.isWorkspace) {
|
||||||
@@ -139,7 +143,7 @@ export class WorkspacesController {
|
|||||||
try {
|
try {
|
||||||
ts = new Date(timestamp);
|
ts = new Date(timestamp);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error('Invalid timestamp');
|
throw new InvalidHistoryTimestamp({ timestamp });
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.permission.checkPagePermission(
|
await this.permission.checkPagePermission(
|
||||||
@@ -160,7 +164,11 @@ export class WorkspacesController {
|
|||||||
res.setHeader('cache-control', 'private, max-age=2592000, immutable');
|
res.setHeader('cache-control', 'private, max-age=2592000, immutable');
|
||||||
res.send(history.blob);
|
res.send(history.blob);
|
||||||
} else {
|
} else {
|
||||||
throw new NotFoundException('Doc history not found');
|
throw new DocHistoryNotFound({
|
||||||
|
workspaceId: docId.workspace,
|
||||||
|
docId: guid,
|
||||||
|
timestamp: ts.getTime(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ForbiddenException } from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Int,
|
Int,
|
||||||
@@ -9,6 +8,7 @@ import {
|
|||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { ActionForbidden } from '../../fundamentals';
|
||||||
import { CurrentUser } from '../auth';
|
import { CurrentUser } from '../auth';
|
||||||
import { Admin } from '../common';
|
import { Admin } from '../common';
|
||||||
import { FeatureManagementService, FeatureType } from '../features';
|
import { FeatureManagementService, FeatureType } from '../features';
|
||||||
@@ -56,13 +56,13 @@ export class WorkspaceManagementResolver {
|
|||||||
@Args('enable') enable: boolean
|
@Args('enable') enable: boolean
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!(await this.feature.canEarlyAccess(user.email))) {
|
if (!(await this.feature.canEarlyAccess(user.email))) {
|
||||||
throw new ForbiddenException('You are not allowed to do this');
|
throw new ActionForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
const owner = await this.permission.getWorkspaceOwner(workspaceId);
|
const owner = await this.permission.getWorkspaceOwner(workspaceId);
|
||||||
const availableFeatures = await this.availableFeatures(user);
|
const availableFeatures = await this.availableFeatures(user);
|
||||||
if (owner.user.id !== user.id || !availableFeatures.includes(feature)) {
|
if (owner.user.id !== user.id || !availableFeatures.includes(feature)) {
|
||||||
throw new ForbiddenException('You are not allowed to do this');
|
throw new ActionForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enable) {
|
if (enable) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { DocAccessDenied, WorkspaceAccessDenied } from '../../fundamentals';
|
||||||
import { Permission } from './types';
|
import { Permission } from './types';
|
||||||
|
|
||||||
export enum PublicPageMode {
|
export enum PublicPageMode {
|
||||||
@@ -151,7 +152,7 @@ export class PermissionService {
|
|||||||
permission: Permission = Permission.Read
|
permission: Permission = Permission.Read
|
||||||
) {
|
) {
|
||||||
if (!(await this.tryCheckWorkspace(ws, user, permission))) {
|
if (!(await this.tryCheckWorkspace(ws, user, permission))) {
|
||||||
throw new ForbiddenException('Permission denied');
|
throw new WorkspaceAccessDenied({ workspaceId: ws });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +324,7 @@ export class PermissionService {
|
|||||||
permission = Permission.Read
|
permission = Permission.Read
|
||||||
) {
|
) {
|
||||||
if (!(await this.tryCheckPage(ws, page, user, permission))) {
|
if (!(await this.tryCheckPage(ws, page, user, permission))) {
|
||||||
throw new ForbiddenException('Permission denied');
|
throw new DocAccessDenied({ workspaceId: ws, docId: page });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Logger, PayloadTooLargeException, UseGuards } from '@nestjs/common';
|
import { Logger, UseGuards } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Int,
|
Int,
|
||||||
@@ -13,6 +13,7 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
|||||||
|
|
||||||
import type { FileUpload } from '../../../fundamentals';
|
import type { FileUpload } from '../../../fundamentals';
|
||||||
import {
|
import {
|
||||||
|
BlobQuotaExceeded,
|
||||||
CloudThrottlerGuard,
|
CloudThrottlerGuard,
|
||||||
MakeCache,
|
MakeCache,
|
||||||
PreventCache,
|
PreventCache,
|
||||||
@@ -126,10 +127,9 @@ export class WorkspaceBlobResolver {
|
|||||||
const checkExceeded =
|
const checkExceeded =
|
||||||
await this.quota.getQuotaCalculatorByWorkspace(workspaceId);
|
await this.quota.getQuotaCalculatorByWorkspace(workspaceId);
|
||||||
|
|
||||||
|
// TODO(@darksky): need a proper way to separate `BlobQuotaExceeded` and `BlobSizeTooLarge`
|
||||||
if (checkExceeded(0)) {
|
if (checkExceeded(0)) {
|
||||||
throw new PayloadTooLargeException(
|
throw new BlobQuotaExceeded();
|
||||||
'Storage or blob size limit exceeded.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const buffer = await new Promise<Buffer>((resolve, reject) => {
|
const buffer = await new Promise<Buffer>((resolve, reject) => {
|
||||||
const stream = blob.createReadStream();
|
const stream = blob.createReadStream();
|
||||||
@@ -140,9 +140,7 @@ export class WorkspaceBlobResolver {
|
|||||||
// check size after receive each chunk to avoid unnecessary memory usage
|
// check size after receive each chunk to avoid unnecessary memory usage
|
||||||
const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0);
|
const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0);
|
||||||
if (checkExceeded(bufferSize)) {
|
if (checkExceeded(bufferSize)) {
|
||||||
reject(
|
reject(new BlobQuotaExceeded());
|
||||||
new PayloadTooLargeException('Storage or blob size limit exceeded.')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
stream.on('error', reject);
|
stream.on('error', reject);
|
||||||
@@ -150,7 +148,7 @@ export class WorkspaceBlobResolver {
|
|||||||
const buffer = Buffer.concat(chunks);
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
if (checkExceeded(buffer.length)) {
|
if (checkExceeded(buffer.length)) {
|
||||||
reject(new PayloadTooLargeException('Storage limit exceeded.'));
|
reject(new BlobQuotaExceeded());
|
||||||
} else {
|
} else {
|
||||||
resolve(buffer);
|
resolve(buffer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,10 +47,6 @@ export class DocHistoryResolver {
|
|||||||
): Promise<DocHistoryType[]> {
|
): Promise<DocHistoryType[]> {
|
||||||
const docId = new DocID(guid, workspace.id);
|
const docId = new DocID(guid, workspace.id);
|
||||||
|
|
||||||
if (docId.isWorkspace) {
|
|
||||||
throw new Error('Invalid guid for listing doc histories.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.historyManager
|
return this.historyManager
|
||||||
.list(workspace.id, docId.guid, timestamp, take)
|
.list(workspace.id, docId.guid, timestamp, take)
|
||||||
.then(rows =>
|
.then(rows =>
|
||||||
@@ -73,10 +69,6 @@ export class DocHistoryResolver {
|
|||||||
): Promise<Date> {
|
): Promise<Date> {
|
||||||
const docId = new DocID(guid, workspaceId);
|
const docId = new DocID(guid, workspaceId);
|
||||||
|
|
||||||
if (docId.isWorkspace) {
|
|
||||||
throw new Error('Invalid guid for recovering doc from history.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.permission.checkPagePermission(
|
await this.permission.checkPagePermission(
|
||||||
docId.workspace,
|
docId.workspace,
|
||||||
docId.guid,
|
docId.guid,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Field,
|
Field,
|
||||||
@@ -12,6 +11,11 @@ import {
|
|||||||
import type { WorkspacePage as PrismaWorkspacePage } from '@prisma/client';
|
import type { WorkspacePage as PrismaWorkspacePage } from '@prisma/client';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ExpectToPublishPage,
|
||||||
|
ExpectToRevokePublicPage,
|
||||||
|
PageIsNotPublic,
|
||||||
|
} from '../../../fundamentals';
|
||||||
import { CurrentUser } from '../../auth';
|
import { CurrentUser } from '../../auth';
|
||||||
import { DocID } from '../../utils/doc';
|
import { DocID } from '../../utils/doc';
|
||||||
import { PermissionService, PublicPageMode } from '../permission';
|
import { PermissionService, PublicPageMode } from '../permission';
|
||||||
@@ -126,7 +130,7 @@ export class PagePermissionResolver {
|
|||||||
const docId = new DocID(pageId, workspaceId);
|
const docId = new DocID(pageId, workspaceId);
|
||||||
|
|
||||||
if (docId.isWorkspace) {
|
if (docId.isWorkspace) {
|
||||||
throw new BadRequestException('Expect page not to be workspace');
|
throw new ExpectToPublishPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.permission.checkWorkspace(
|
await this.permission.checkWorkspace(
|
||||||
@@ -163,7 +167,7 @@ export class PagePermissionResolver {
|
|||||||
const docId = new DocID(pageId, workspaceId);
|
const docId = new DocID(pageId, workspaceId);
|
||||||
|
|
||||||
if (docId.isWorkspace) {
|
if (docId.isWorkspace) {
|
||||||
throw new BadRequestException('Expect page not to be workspace');
|
throw new ExpectToRevokePublicPage('Expect page not to be workspace');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.permission.checkWorkspace(
|
await this.permission.checkWorkspace(
|
||||||
@@ -178,7 +182,7 @@ export class PagePermissionResolver {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!isPublic) {
|
if (!isPublic) {
|
||||||
throw new BadRequestException('Page is not public');
|
throw new PageIsNotPublic('Page is not public');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.permission.revokePublicPage(docId.workspace, docId.guid);
|
return this.permission.revokePublicPage(docId.workspace, docId.guid);
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { Logger } from '@nestjs/common';
|
||||||
ForbiddenException,
|
|
||||||
InternalServerErrorException,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
PayloadTooLargeException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Int,
|
Int,
|
||||||
@@ -21,11 +15,18 @@ import { applyUpdate, Doc } from 'yjs';
|
|||||||
|
|
||||||
import type { FileUpload } from '../../../fundamentals';
|
import type { FileUpload } from '../../../fundamentals';
|
||||||
import {
|
import {
|
||||||
|
CantChangeWorkspaceOwner,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
|
InternalServerError,
|
||||||
MailService,
|
MailService,
|
||||||
|
MemberQuotaExceeded,
|
||||||
MutexService,
|
MutexService,
|
||||||
Throttle,
|
Throttle,
|
||||||
TooManyRequestsException,
|
TooManyRequest,
|
||||||
|
UserNotFound,
|
||||||
|
WorkspaceAccessDenied,
|
||||||
|
WorkspaceNotFound,
|
||||||
|
WorkspaceOwnerNotFound,
|
||||||
} from '../../../fundamentals';
|
} from '../../../fundamentals';
|
||||||
import { CurrentUser, Public } from '../../auth';
|
import { CurrentUser, Public } from '../../auth';
|
||||||
import { QuotaManagementService, QuotaQueryType } from '../../quota';
|
import { QuotaManagementService, QuotaQueryType } from '../../quota';
|
||||||
@@ -77,7 +78,7 @@ export class WorkspaceResolver {
|
|||||||
const permission = await this.permissions.get(workspace.id, user.id);
|
const permission = await this.permissions.get(workspace.id, user.id);
|
||||||
|
|
||||||
if (!permission) {
|
if (!permission) {
|
||||||
throw new ForbiddenException();
|
throw new WorkspaceAccessDenied({ workspaceId: workspace.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
return permission;
|
return permission;
|
||||||
@@ -196,7 +197,7 @@ export class WorkspaceResolver {
|
|||||||
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
|
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
throw new NotFoundException("Workspace doesn't exist");
|
throw new WorkspaceNotFound({ workspaceId: id });
|
||||||
}
|
}
|
||||||
|
|
||||||
return workspace;
|
return workspace;
|
||||||
@@ -307,7 +308,7 @@ export class WorkspaceResolver {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (permission === Permission.Owner) {
|
if (permission === Permission.Owner) {
|
||||||
throw new ForbiddenException('Cannot change owner');
|
throw new CantChangeWorkspaceOwner();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -315,7 +316,7 @@ export class WorkspaceResolver {
|
|||||||
const lockFlag = `invite:${workspaceId}`;
|
const lockFlag = `invite:${workspaceId}`;
|
||||||
await using lock = await this.mutex.lock(lockFlag);
|
await using lock = await this.mutex.lock(lockFlag);
|
||||||
if (!lock) {
|
if (!lock) {
|
||||||
return new TooManyRequestsException('Server is busy');
|
return new TooManyRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
// member limit check
|
// member limit check
|
||||||
@@ -326,7 +327,7 @@ export class WorkspaceResolver {
|
|||||||
this.quota.getWorkspaceUsage(workspaceId),
|
this.quota.getWorkspaceUsage(workspaceId),
|
||||||
]);
|
]);
|
||||||
if (memberCount >= quota.memberLimit) {
|
if (memberCount >= quota.memberLimit) {
|
||||||
return new PayloadTooLargeException('Workspace member limit reached.');
|
return new MemberQuotaExceeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
let target = await this.users.findUserByEmail(email);
|
let target = await this.users.findUserByEmail(email);
|
||||||
@@ -381,7 +382,7 @@ export class WorkspaceResolver {
|
|||||||
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
|
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return new InternalServerErrorException(
|
throw new InternalServerError(
|
||||||
'Failed to send invite email. Please try again.'
|
'Failed to send invite email. Please try again.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -389,7 +390,7 @@ export class WorkspaceResolver {
|
|||||||
return inviteId;
|
return inviteId;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error('failed to invite user', e);
|
this.logger.error('failed to invite user', e);
|
||||||
return new TooManyRequestsException('Server is busy');
|
return new TooManyRequest();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,9 +482,7 @@ export class WorkspaceResolver {
|
|||||||
} = await this.getInviteInfo(inviteId);
|
} = await this.getInviteInfo(inviteId);
|
||||||
|
|
||||||
if (!inviter || !invitee) {
|
if (!inviter || !invitee) {
|
||||||
throw new ForbiddenException(
|
throw new UserNotFound();
|
||||||
`can not find inviter/invitee by inviteId: ${inviteId}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sendAcceptMail) {
|
if (sendAcceptMail) {
|
||||||
@@ -508,9 +507,7 @@ export class WorkspaceResolver {
|
|||||||
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
|
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
|
||||||
|
|
||||||
if (!owner.user) {
|
if (!owner.user) {
|
||||||
throw new ForbiddenException(
|
throw new WorkspaceOwnerNotFound({ workspaceId: workspaceId });
|
||||||
`can not find owner by workspaceId: ${workspaceId}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sendLeaveMail) {
|
if (sendLeaveMail) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
|
||||||
forwardRef,
|
forwardRef,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -10,6 +9,7 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
import { difference, keyBy } from 'lodash-es';
|
import { difference, keyBy } from 'lodash-es';
|
||||||
|
|
||||||
import { Cache } from '../../cache';
|
import { Cache } from '../../cache';
|
||||||
|
import { InvalidRuntimeConfigType, RuntimeConfigNotFound } from '../../error';
|
||||||
import { defer } from '../../utils/promise';
|
import { defer } from '../../utils/promise';
|
||||||
import { defaultRuntimeConfig, runtimeConfigType } from '../register';
|
import { defaultRuntimeConfig, runtimeConfigType } from '../register';
|
||||||
import { AppRuntimeConfigModules, FlattenedAppRuntimeConfig } from '../types';
|
import { AppRuntimeConfigModules, FlattenedAppRuntimeConfig } from '../types';
|
||||||
@@ -21,15 +21,17 @@ function validateConfigType<K extends keyof FlattenedAppRuntimeConfig>(
|
|||||||
const config = defaultRuntimeConfig[key];
|
const config = defaultRuntimeConfig[key];
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new BadRequestException(`Unknown runtime config key '${key}'`);
|
throw new RuntimeConfigNotFound({ key });
|
||||||
}
|
}
|
||||||
|
|
||||||
const want = config.type;
|
const want = config.type;
|
||||||
const get = runtimeConfigType(value);
|
const get = runtimeConfigType(value);
|
||||||
if (get !== want) {
|
if (get !== want) {
|
||||||
throw new BadRequestException(
|
throw new InvalidRuntimeConfigType({
|
||||||
`Invalid runtime config type for '${key}', want '${want}', but get '${get}'`
|
key,
|
||||||
);
|
want,
|
||||||
|
get,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +70,7 @@ export class Runtime implements OnApplicationBootstrap {
|
|||||||
const dbValue = await this.loadDb<K>(k);
|
const dbValue = await this.loadDb<K>(k);
|
||||||
|
|
||||||
if (dbValue === undefined) {
|
if (dbValue === undefined) {
|
||||||
throw new Error(`Runtime config ${k} not found`);
|
throw new RuntimeConfigNotFound({ key: k });
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.setCache(k, dbValue);
|
await this.setCache(k, dbValue);
|
||||||
|
|||||||
481
packages/backend/server/src/fundamentals/error/def.ts
Normal file
481
packages/backend/server/src/fundamentals/error/def.ts
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
import { STATUS_CODES } from 'node:http';
|
||||||
|
|
||||||
|
import { HttpStatus, Logger } from '@nestjs/common';
|
||||||
|
import { capitalize } from 'lodash-es';
|
||||||
|
|
||||||
|
export type UserFriendlyErrorBaseType =
|
||||||
|
| 'bad_request'
|
||||||
|
| 'too_many_requests'
|
||||||
|
| 'resource_not_found'
|
||||||
|
| 'resource_already_exists'
|
||||||
|
| 'invalid_input'
|
||||||
|
| 'action_forbidden'
|
||||||
|
| 'no_permission'
|
||||||
|
| 'quota_exceeded'
|
||||||
|
| 'authentication_required'
|
||||||
|
| 'internal_server_error';
|
||||||
|
|
||||||
|
type ErrorArgType = 'string' | 'number' | 'boolean';
|
||||||
|
type ErrorArgs = Record<string, ErrorArgType | Record<string, ErrorArgType>>;
|
||||||
|
|
||||||
|
export type UserFriendlyErrorOptions = {
|
||||||
|
type: UserFriendlyErrorBaseType;
|
||||||
|
args?: ErrorArgs;
|
||||||
|
message: string | ((args: any) => string);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BaseTypeToHttpStatusMap: Record<UserFriendlyErrorBaseType, HttpStatus> = {
|
||||||
|
too_many_requests: HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
bad_request: HttpStatus.BAD_REQUEST,
|
||||||
|
resource_not_found: HttpStatus.NOT_FOUND,
|
||||||
|
resource_already_exists: HttpStatus.BAD_REQUEST,
|
||||||
|
invalid_input: HttpStatus.BAD_REQUEST,
|
||||||
|
action_forbidden: HttpStatus.FORBIDDEN,
|
||||||
|
no_permission: HttpStatus.FORBIDDEN,
|
||||||
|
quota_exceeded: HttpStatus.PAYMENT_REQUIRED,
|
||||||
|
authentication_required: HttpStatus.UNAUTHORIZED,
|
||||||
|
internal_server_error: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class UserFriendlyError extends Error {
|
||||||
|
/**
|
||||||
|
* Standard HTTP status code
|
||||||
|
*/
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Business error category, for example 'resource_already_exists' or 'quota_exceeded'
|
||||||
|
*/
|
||||||
|
type: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional data that could be used for error handling or formatting
|
||||||
|
*/
|
||||||
|
data: any;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
type: UserFriendlyErrorBaseType,
|
||||||
|
name: keyof typeof USER_FRIENDLY_ERRORS,
|
||||||
|
message?: string | ((args?: any) => string),
|
||||||
|
args?: any
|
||||||
|
) {
|
||||||
|
const defaultMsg = USER_FRIENDLY_ERRORS[name].message;
|
||||||
|
// disallow message override for `internal_server_error`
|
||||||
|
// to avoid leak internal information to user
|
||||||
|
let msg =
|
||||||
|
name === 'internal_server_error' ? defaultMsg : message ?? defaultMsg;
|
||||||
|
|
||||||
|
if (typeof msg === 'function') {
|
||||||
|
msg = msg(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
super(msg);
|
||||||
|
this.status = BaseTypeToHttpStatusMap[type];
|
||||||
|
this.type = type;
|
||||||
|
this.name = name;
|
||||||
|
this.data = args;
|
||||||
|
}
|
||||||
|
|
||||||
|
json() {
|
||||||
|
return {
|
||||||
|
status: this.status,
|
||||||
|
code: STATUS_CODES[this.status] ?? 'BAD REQUEST',
|
||||||
|
type: this.type.toUpperCase(),
|
||||||
|
name: this.name.toUpperCase(),
|
||||||
|
message: this.message,
|
||||||
|
data: this.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
log(context: string) {
|
||||||
|
// ignore all user behavior error log
|
||||||
|
if (this.type !== 'internal_server_error') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
new Logger(context).error(
|
||||||
|
'Internal server error',
|
||||||
|
this.cause ? (this.cause as any).stack ?? this.cause : this.stack
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @ObjectType()
|
||||||
|
* export class XXXDataType {
|
||||||
|
* @Field()
|
||||||
|
*
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
function generateErrorArgs(name: string, args: ErrorArgs) {
|
||||||
|
const typeName = `${name}DataType`;
|
||||||
|
const lines = [`@ObjectType()`, `class ${typeName} {`];
|
||||||
|
Object.entries(args).forEach(([arg, fieldArgs]) => {
|
||||||
|
if (typeof fieldArgs === 'object') {
|
||||||
|
const subResult = generateErrorArgs(
|
||||||
|
name + 'Field' + capitalize(arg),
|
||||||
|
fieldArgs
|
||||||
|
);
|
||||||
|
lines.unshift(subResult.def);
|
||||||
|
lines.push(
|
||||||
|
` @Field(() => ${subResult.name}) ${arg}!: ${subResult.name};`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
lines.push(` @Field() ${arg}!: ${fieldArgs}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
lines.push('}');
|
||||||
|
|
||||||
|
return { name: typeName, def: lines.join('\n') };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateUserFriendlyErrors() {
|
||||||
|
const output = [
|
||||||
|
'// AUTO GENERATED FILE',
|
||||||
|
`import { createUnionType, Field, ObjectType, registerEnumType } from '@nestjs/graphql';`,
|
||||||
|
'',
|
||||||
|
`import { UserFriendlyError } from './def';`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const errorNames: string[] = [];
|
||||||
|
const argTypes: string[] = [];
|
||||||
|
|
||||||
|
for (const code in USER_FRIENDLY_ERRORS) {
|
||||||
|
errorNames.push(code.toUpperCase());
|
||||||
|
// @ts-expect-error allow
|
||||||
|
const options: UserFriendlyErrorOptions = USER_FRIENDLY_ERRORS[code];
|
||||||
|
const className = code
|
||||||
|
.split('_')
|
||||||
|
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const args = options.args
|
||||||
|
? generateErrorArgs(className, options.args)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const classDef = `
|
||||||
|
export class ${className} extends UserFriendlyError {
|
||||||
|
constructor(${args ? `args: ${args.name}, ` : ''}message?: string${args ? ` | ((args: ${args.name}) => string)` : ''}) {
|
||||||
|
super('${options.type}', '${code}', message${args ? ', args' : ''});
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
if (args) {
|
||||||
|
output.push(args.def);
|
||||||
|
argTypes.push(args.name);
|
||||||
|
}
|
||||||
|
output.push(classDef);
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push(`export enum ErrorNames {
|
||||||
|
${errorNames.join(',\n ')}
|
||||||
|
}
|
||||||
|
registerEnumType(ErrorNames, {
|
||||||
|
name: 'ErrorNames'
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ErrorDataUnionType = createUnionType({
|
||||||
|
name: 'ErrorDataUnion',
|
||||||
|
types: () =>
|
||||||
|
[${argTypes.join(', ')}] as const,
|
||||||
|
});
|
||||||
|
`);
|
||||||
|
|
||||||
|
return output.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEFINE ALL USER FRIENDLY ERRORS HERE
|
||||||
|
export const USER_FRIENDLY_ERRORS = {
|
||||||
|
// Internal uncaught errors
|
||||||
|
internal_server_error: {
|
||||||
|
type: 'internal_server_error',
|
||||||
|
message: 'An internal error occurred.',
|
||||||
|
},
|
||||||
|
too_many_request: {
|
||||||
|
type: 'too_many_requests',
|
||||||
|
message: 'Too many requests.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// User Errors
|
||||||
|
user_not_found: {
|
||||||
|
type: 'resource_not_found',
|
||||||
|
message: 'User not found.',
|
||||||
|
},
|
||||||
|
user_avatar_not_found: {
|
||||||
|
type: 'resource_not_found',
|
||||||
|
message: 'User avatar not found.',
|
||||||
|
},
|
||||||
|
email_already_used: {
|
||||||
|
type: 'resource_already_exists',
|
||||||
|
message: 'This email has already been registered.',
|
||||||
|
},
|
||||||
|
same_email_provided: {
|
||||||
|
type: 'invalid_input',
|
||||||
|
message:
|
||||||
|
'You are trying to update your account email to the same as the old one.',
|
||||||
|
},
|
||||||
|
wrong_sign_in_credentials: {
|
||||||
|
type: 'invalid_input',
|
||||||
|
message: 'Wrong user email or password.',
|
||||||
|
},
|
||||||
|
unknown_oauth_provider: {
|
||||||
|
type: 'invalid_input',
|
||||||
|
args: { name: 'string' },
|
||||||
|
message: ({ name }) => `Unknown authentication provider ${name}.`,
|
||||||
|
},
|
||||||
|
oauth_state_expired: {
|
||||||
|
type: 'bad_request',
|
||||||
|
message: 'OAuth state expired, please try again.',
|
||||||
|
},
|
||||||
|
invalid_oauth_callback_state: {
|
||||||
|
type: 'bad_request',
|
||||||
|
message: 'Invalid callback state parameter.',
|
||||||
|
},
|
||||||
|
missing_oauth_query_parameter: {
|
||||||
|
type: 'bad_request',
|
||||||
|
args: { name: 'string' },
|
||||||
|
message: ({ name }) => `Missing query parameter \`${name}\`.`,
|
||||||
|
},
|
||||||
|
oauth_account_already_connected: {
|
||||||
|
type: 'bad_request',
|
||||||
|
message:
|
||||||
|
'The third-party account has already been connected to another user.',
|
||||||
|
},
|
||||||
|
invalid_email: {
|
||||||
|
type: 'invalid_input',
|
||||||
|
message: 'An invalid email provided.',
|
||||||
|
},
|
||||||
|
invalid_password_length: {
|
||||||
|
type: 'invalid_input',
|
||||||
|
args: { min: 'number', max: 'number' },
|
||||||
|
message: ({ min, max }) =>
|
||||||
|
`Password must be between ${min} and ${max} characters`,
|
||||||
|
},
|
||||||
|
wrong_sign_in_method: {
|
||||||
|
type: 'invalid_input',
|
||||||
|
message:
|
||||||
|
'You are trying to sign in by a different method than you signed up with.',
|
||||||
|
},
|
||||||
|
early_access_required: {
|
||||||
|
type: 'action_forbidden',
|
||||||
|
message: `You don't have early access permission. Visit https://community.affine.pro/c/insider-general/ for more information.`,
|
||||||
|
},
|
||||||
|
sign_up_forbidden: {
|
||||||
|
type: 'action_forbidden',
|
||||||
|
message: `You are not allowed to sign up.`,
|
||||||
|
},
|
||||||
|
email_token_not_found: {
|
||||||
|
type: 'invalid_input',
|
||||||
|
message: 'The email token provided is not found.',
|
||||||
|
},
|
||||||
|
invalid_email_token: {
|
||||||
|
type: 'invalid_input',
|
||||||
|
message: 'An invalid email token provided.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Authentication & Permission Errors
|
||||||
|
authentication_required: {
|
||||||
|
type: 'authentication_required',
|
||||||
|
message: 'You must sign in first to access this resource.',
|
||||||
|
},
|
||||||
|
action_forbidden: {
|
||||||
|
type: 'action_forbidden',
|
||||||
|
message: 'You are not allowed to perform this action.',
|
||||||
|
},
|
||||||
|
access_denied: {
|
||||||
|
type: 'no_permission',
|
||||||
|
message: 'You do not have permission to access this resource.',
|
||||||
|
},
|
||||||
|
email_verification_required: {
|
||||||
|
type: 'action_forbidden',
|
||||||
|
message: 'You must verify your email before accessing this resource.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Workspace & Doc & Sync errors
|
||||||
|
workspace_not_found: {
|
||||||
|
type: 'resource_not_found',
|
||||||
|
args: { workspaceId: 'string' },
|
||||||
|
message: ({ workspaceId }) => `Workspace ${workspaceId} not found.`,
|
||||||
|
},
|
||||||
|
not_in_workspace: {
|
||||||
|
type: 'action_forbidden',
|
||||||
|
args: { workspaceId: 'string' },
|
||||||
|
message: ({ workspaceId }) =>
|
||||||
|
`You should join in workspace ${workspaceId} before broadcasting messages.`,
|
||||||
|
},
|
||||||
|
workspace_access_denied: {
|
||||||
|
type: 'no_permission',
|
||||||
|
args: { workspaceId: 'string' },
|
||||||
|
message: ({ workspaceId }) =>
|
||||||
|
`You do not have permission to access workspace ${workspaceId}.`,
|
||||||
|
},
|
||||||
|
workspace_owner_not_found: {
|
||||||
|
type: 'internal_server_error',
|
||||||
|
args: { workspaceId: 'string' },
|
||||||
|
message: ({ workspaceId }) =>
|
||||||
|
`Owner of workspace ${workspaceId} not found.`,
|
||||||
|
},
|
||||||
|
cant_change_workspace_owner: {
|
||||||
|
type: 'action_forbidden',
|
||||||
|
message: 'You are not allowed to change the owner of a workspace.',
|
||||||
|
},
|
||||||
|
doc_not_found: {
|
||||||
|
type: 'resource_not_found',
|
||||||
|
args: { workspaceId: 'string', docId: 'string' },
|
||||||
|
message: ({ workspaceId, docId }) =>
|
||||||
|
`Doc ${docId} under workspace ${workspaceId} not found.`,
|
||||||
|
},
|
||||||
|
doc_access_denied: {
|
||||||
|
type: 'no_permission',
|
||||||
|
args: { workspaceId: 'string', docId: 'string' },
|
||||||
|
message: ({ workspaceId, docId }) =>
|
||||||
|
`You do not have permission to access doc ${docId} under workspace ${workspaceId}.`,
|
||||||
|
},
|
||||||
|
version_rejected: {
|
||||||
|
type: 'action_forbidden',
|
||||||
|
args: { version: 'string', serverVersion: 'string' },
|
||||||
|
message: ({ version, serverVersion }) =>
|
||||||
|
`Your client with version ${version} is rejected by remote sync server. Please upgrade to ${serverVersion}.`,
|
||||||
|
},
|
||||||
|
invalid_history_timestamp: {
|
||||||
|
type: 'invalid_input',
|
||||||
|
args: { timestamp: 'string' },
|
||||||
|
message: 'Invalid doc history timestamp provided.',
|
||||||
|
},
|
||||||
|
doc_history_not_found: {
|
||||||
|
type: 'resource_not_found',
|
||||||
|
args: { workspaceId: 'string', docId: 'string', timestamp: 'number' },
|
||||||
|
message: ({ workspaceId, docId, timestamp }) =>
|
||||||
|
`History of ${docId} at ${timestamp} under workspace ${workspaceId}.`,
|
||||||
|
},
|
||||||
|
blob_not_found: {
|
||||||
|
type: 'resource_not_found',
|
||||||
|
args: { workspaceId: 'string', blobId: 'string' },
|
||||||
|
message: ({ workspaceId, blobId }) =>
|
||||||
|
`Blob ${blobId} not found in workspace ${workspaceId}.`,
|
||||||
|
},
|
||||||
|
expect_to_publish_page: {
|
||||||
|
type: 'invalid_input',
|
||||||
|
message: 'Expected to publish a page, not a workspace.',
|
||||||
|
},
|
||||||
|
expect_to_revoke_public_page: {
|
||||||
|
type: 'invalid_input',
|
||||||
|
message: 'Expected to revoke a public page, not a workspace.',
|
||||||
|
},
|
||||||
|
page_is_not_public: {
|
||||||
|
type: 'bad_request',
|
||||||
|
message: 'Page is not public.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Subscription Errors
|
||||||
|
failed_to_checkout: {
|
||||||
|
type: 'internal_server_error',
|
||||||
|
message: 'Failed to create checkout session.',
|
||||||
|
},
|
||||||
|
subscription_already_exists: {
|
||||||
|
type: 'resource_already_exists',
|
||||||
|
args: { plan: 'string' },
|
||||||
|
message: ({ plan }) => `You have already subscribed to the ${plan} plan.`,
|
||||||
|
},
|
||||||
|
subscription_not_exists: {
|
||||||
|
type: 'resource_not_found',
|
||||||
|
args: { plan: 'string' },
|
||||||
|
message: ({ plan }) => `You didn't subscribe to the ${plan} plan.`,
|
||||||
|
},
|
||||||
|
subscription_has_been_canceled: {
|
||||||
|
type: 'action_forbidden',
|
||||||
|
message: 'Your subscription has already been canceled.',
|
||||||
|
},
|
||||||
|
subscription_expired: {
|
||||||
|
type: 'action_forbidden',
|
||||||
|
message: 'Your subscription has expired.',
|
||||||
|
},
|
||||||
|
same_subscription_recurring: {
|
||||||
|
type: 'bad_request',
|
||||||
|
args: { recurring: 'string' },
|
||||||
|
message: ({ recurring }) =>
|
||||||
|
`Your subscription has already been in ${recurring} recurring state.`,
|
||||||
|
},
|
||||||
|
customer_portal_create_failed: {
|
||||||
|
type: 'internal_server_error',
|
||||||
|
message: 'Failed to create customer portal session.',
|
||||||
|
},
|
||||||
|
subscription_plan_not_found: {
|
||||||
|
type: 'resource_not_found',
|
||||||
|
args: { plan: 'string', recurring: 'string' },
|
||||||
|
message: 'You are trying to access a unknown subscription plan.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Copilot errors
|
||||||
|
copilot_session_not_found: {
|
||||||
|
type: 'resource_not_found',
|
||||||
|
message: `Copilot session not found.`,
|
||||||
|
},
|
||||||
|
copilot_session_deleted: {
|
||||||
|
type: 'action_forbidden',
|
||||||
|
message: `Copilot session has been deleted.`,
|
||||||
|
},
|
||||||
|
no_copilot_provider_available: {
|
||||||
|
type: 'internal_server_error',
|
||||||
|
message: `No copilot provider available.`,
|
||||||
|
},
|
||||||
|
copilot_failed_to_generate_text: {
|
||||||
|
type: 'internal_server_error',
|
||||||
|
message: `Failed to generate text.`,
|
||||||
|
},
|
||||||
|
copilot_failed_to_create_message: {
|
||||||
|
type: 'internal_server_error',
|
||||||
|
message: `Failed to create chat message.`,
|
||||||
|
},
|
||||||
|
unsplash_is_not_configured: {
|
||||||
|
type: 'internal_server_error',
|
||||||
|
message: `Unsplash is not configured.`,
|
||||||
|
},
|
||||||
|
copilot_action_taken: {
|
||||||
|
type: 'action_forbidden',
|
||||||
|
message: `Action has been taken, no more messages allowed.`,
|
||||||
|
},
|
||||||
|
copilot_message_not_found: {
|
||||||
|
type: 'resource_not_found',
|
||||||
|
message: `Copilot message not found.`,
|
||||||
|
},
|
||||||
|
copilot_prompt_not_found: {
|
||||||
|
type: 'resource_not_found',
|
||||||
|
args: { name: 'string' },
|
||||||
|
message: ({ name }) => `Copilot prompt ${name} not found.`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Quota & Limit errors
|
||||||
|
blob_quota_exceeded: {
|
||||||
|
type: 'quota_exceeded',
|
||||||
|
message: 'You have exceeded your blob storage quota.',
|
||||||
|
},
|
||||||
|
member_quota_exceeded: {
|
||||||
|
type: 'quota_exceeded',
|
||||||
|
message: 'You have exceeded your workspace member quota.',
|
||||||
|
},
|
||||||
|
copilot_quota_exceeded: {
|
||||||
|
type: 'quota_exceeded',
|
||||||
|
message:
|
||||||
|
'You have reached the limit of actions in this workspace, please upgrade your plan.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Config errors
|
||||||
|
runtime_config_not_found: {
|
||||||
|
type: 'resource_not_found',
|
||||||
|
args: { key: 'string' },
|
||||||
|
message: ({ key }) => `Runtime config ${key} not found.`,
|
||||||
|
},
|
||||||
|
invalid_runtime_config_type: {
|
||||||
|
type: 'invalid_input',
|
||||||
|
args: { key: 'string', want: 'string', get: 'string' },
|
||||||
|
message: ({ key, want, get }) =>
|
||||||
|
`Invalid runtime config type for '${key}', want '${want}', but get ${get}.`,
|
||||||
|
},
|
||||||
|
mailer_service_is_not_configured: {
|
||||||
|
type: 'internal_server_error',
|
||||||
|
message: 'Mailer service is not configured.',
|
||||||
|
},
|
||||||
|
} satisfies Record<string, UserFriendlyErrorOptions>;
|
||||||
616
packages/backend/server/src/fundamentals/error/errors.gen.ts
Normal file
616
packages/backend/server/src/fundamentals/error/errors.gen.ts
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
// AUTO GENERATED FILE
|
||||||
|
import {
|
||||||
|
createUnionType,
|
||||||
|
Field,
|
||||||
|
ObjectType,
|
||||||
|
registerEnumType,
|
||||||
|
} from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { UserFriendlyError } from './def';
|
||||||
|
|
||||||
|
export class InternalServerError extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('internal_server_error', 'internal_server_error', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TooManyRequest extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('too_many_requests', 'too_many_request', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserNotFound extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('resource_not_found', 'user_not_found', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserAvatarNotFound extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('resource_not_found', 'user_avatar_not_found', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmailAlreadyUsed extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('resource_already_exists', 'email_already_used', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SameEmailProvided extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('invalid_input', 'same_email_provided', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WrongSignInCredentials extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('invalid_input', 'wrong_sign_in_credentials', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class UnknownOauthProviderDataType {
|
||||||
|
@Field() name!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnknownOauthProvider extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: UnknownOauthProviderDataType,
|
||||||
|
message?: string | ((args: UnknownOauthProviderDataType) => string)
|
||||||
|
) {
|
||||||
|
super('invalid_input', 'unknown_oauth_provider', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OauthStateExpired extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('bad_request', 'oauth_state_expired', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidOauthCallbackState extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('bad_request', 'invalid_oauth_callback_state', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class MissingOauthQueryParameterDataType {
|
||||||
|
@Field() name!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MissingOauthQueryParameter extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: MissingOauthQueryParameterDataType,
|
||||||
|
message?: string | ((args: MissingOauthQueryParameterDataType) => string)
|
||||||
|
) {
|
||||||
|
super('bad_request', 'missing_oauth_query_parameter', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OauthAccountAlreadyConnected extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('bad_request', 'oauth_account_already_connected', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidEmail extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('invalid_input', 'invalid_email', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class InvalidPasswordLengthDataType {
|
||||||
|
@Field() min!: number;
|
||||||
|
@Field() max!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidPasswordLength extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: InvalidPasswordLengthDataType,
|
||||||
|
message?: string | ((args: InvalidPasswordLengthDataType) => string)
|
||||||
|
) {
|
||||||
|
super('invalid_input', 'invalid_password_length', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WrongSignInMethod extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('invalid_input', 'wrong_sign_in_method', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EarlyAccessRequired extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('action_forbidden', 'early_access_required', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SignUpForbidden extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('action_forbidden', 'sign_up_forbidden', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmailTokenNotFound extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('invalid_input', 'email_token_not_found', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidEmailToken extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('invalid_input', 'invalid_email_token', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthenticationRequired extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('authentication_required', 'authentication_required', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ActionForbidden extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('action_forbidden', 'action_forbidden', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AccessDenied extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('no_permission', 'access_denied', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmailVerificationRequired extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('action_forbidden', 'email_verification_required', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class WorkspaceNotFoundDataType {
|
||||||
|
@Field() workspaceId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkspaceNotFound extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: WorkspaceNotFoundDataType,
|
||||||
|
message?: string | ((args: WorkspaceNotFoundDataType) => string)
|
||||||
|
) {
|
||||||
|
super('resource_not_found', 'workspace_not_found', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class NotInWorkspaceDataType {
|
||||||
|
@Field() workspaceId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotInWorkspace extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: NotInWorkspaceDataType,
|
||||||
|
message?: string | ((args: NotInWorkspaceDataType) => string)
|
||||||
|
) {
|
||||||
|
super('action_forbidden', 'not_in_workspace', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class WorkspaceAccessDeniedDataType {
|
||||||
|
@Field() workspaceId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkspaceAccessDenied extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: WorkspaceAccessDeniedDataType,
|
||||||
|
message?: string | ((args: WorkspaceAccessDeniedDataType) => string)
|
||||||
|
) {
|
||||||
|
super('no_permission', 'workspace_access_denied', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class WorkspaceOwnerNotFoundDataType {
|
||||||
|
@Field() workspaceId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkspaceOwnerNotFound extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: WorkspaceOwnerNotFoundDataType,
|
||||||
|
message?: string | ((args: WorkspaceOwnerNotFoundDataType) => string)
|
||||||
|
) {
|
||||||
|
super('internal_server_error', 'workspace_owner_not_found', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CantChangeWorkspaceOwner extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('action_forbidden', 'cant_change_workspace_owner', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class DocNotFoundDataType {
|
||||||
|
@Field() workspaceId!: string;
|
||||||
|
@Field() docId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocNotFound extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: DocNotFoundDataType,
|
||||||
|
message?: string | ((args: DocNotFoundDataType) => string)
|
||||||
|
) {
|
||||||
|
super('resource_not_found', 'doc_not_found', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class DocAccessDeniedDataType {
|
||||||
|
@Field() workspaceId!: string;
|
||||||
|
@Field() docId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocAccessDenied extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: DocAccessDeniedDataType,
|
||||||
|
message?: string | ((args: DocAccessDeniedDataType) => string)
|
||||||
|
) {
|
||||||
|
super('no_permission', 'doc_access_denied', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class VersionRejectedDataType {
|
||||||
|
@Field() version!: string;
|
||||||
|
@Field() serverVersion!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VersionRejected extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: VersionRejectedDataType,
|
||||||
|
message?: string | ((args: VersionRejectedDataType) => string)
|
||||||
|
) {
|
||||||
|
super('action_forbidden', 'version_rejected', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class InvalidHistoryTimestampDataType {
|
||||||
|
@Field() timestamp!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidHistoryTimestamp extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: InvalidHistoryTimestampDataType,
|
||||||
|
message?: string | ((args: InvalidHistoryTimestampDataType) => string)
|
||||||
|
) {
|
||||||
|
super('invalid_input', 'invalid_history_timestamp', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class DocHistoryNotFoundDataType {
|
||||||
|
@Field() workspaceId!: string;
|
||||||
|
@Field() docId!: string;
|
||||||
|
@Field() timestamp!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocHistoryNotFound extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: DocHistoryNotFoundDataType,
|
||||||
|
message?: string | ((args: DocHistoryNotFoundDataType) => string)
|
||||||
|
) {
|
||||||
|
super('resource_not_found', 'doc_history_not_found', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class BlobNotFoundDataType {
|
||||||
|
@Field() workspaceId!: string;
|
||||||
|
@Field() blobId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BlobNotFound extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: BlobNotFoundDataType,
|
||||||
|
message?: string | ((args: BlobNotFoundDataType) => string)
|
||||||
|
) {
|
||||||
|
super('resource_not_found', 'blob_not_found', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExpectToPublishPage extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('invalid_input', 'expect_to_publish_page', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExpectToRevokePublicPage extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('invalid_input', 'expect_to_revoke_public_page', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PageIsNotPublic extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('bad_request', 'page_is_not_public', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FailedToCheckout extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('internal_server_error', 'failed_to_checkout', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class SubscriptionAlreadyExistsDataType {
|
||||||
|
@Field() plan!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubscriptionAlreadyExists extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: SubscriptionAlreadyExistsDataType,
|
||||||
|
message?: string | ((args: SubscriptionAlreadyExistsDataType) => string)
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
'resource_already_exists',
|
||||||
|
'subscription_already_exists',
|
||||||
|
message,
|
||||||
|
args
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class SubscriptionNotExistsDataType {
|
||||||
|
@Field() plan!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubscriptionNotExists extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: SubscriptionNotExistsDataType,
|
||||||
|
message?: string | ((args: SubscriptionNotExistsDataType) => string)
|
||||||
|
) {
|
||||||
|
super('resource_not_found', 'subscription_not_exists', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubscriptionHasBeenCanceled extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('action_forbidden', 'subscription_has_been_canceled', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubscriptionExpired extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('action_forbidden', 'subscription_expired', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class SameSubscriptionRecurringDataType {
|
||||||
|
@Field() recurring!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SameSubscriptionRecurring extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: SameSubscriptionRecurringDataType,
|
||||||
|
message?: string | ((args: SameSubscriptionRecurringDataType) => string)
|
||||||
|
) {
|
||||||
|
super('bad_request', 'same_subscription_recurring', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CustomerPortalCreateFailed extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('internal_server_error', 'customer_portal_create_failed', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class SubscriptionPlanNotFoundDataType {
|
||||||
|
@Field() plan!: string;
|
||||||
|
@Field() recurring!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubscriptionPlanNotFound extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: SubscriptionPlanNotFoundDataType,
|
||||||
|
message?: string | ((args: SubscriptionPlanNotFoundDataType) => string)
|
||||||
|
) {
|
||||||
|
super('resource_not_found', 'subscription_plan_not_found', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CopilotSessionNotFound extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('resource_not_found', 'copilot_session_not_found', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CopilotSessionDeleted extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('action_forbidden', 'copilot_session_deleted', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NoCopilotProviderAvailable extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('internal_server_error', 'no_copilot_provider_available', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CopilotFailedToGenerateText extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('internal_server_error', 'copilot_failed_to_generate_text', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CopilotFailedToCreateMessage extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('internal_server_error', 'copilot_failed_to_create_message', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnsplashIsNotConfigured extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('internal_server_error', 'unsplash_is_not_configured', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CopilotActionTaken extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('action_forbidden', 'copilot_action_taken', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CopilotMessageNotFound extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('resource_not_found', 'copilot_message_not_found', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class CopilotPromptNotFoundDataType {
|
||||||
|
@Field() name!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CopilotPromptNotFound extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: CopilotPromptNotFoundDataType,
|
||||||
|
message?: string | ((args: CopilotPromptNotFoundDataType) => string)
|
||||||
|
) {
|
||||||
|
super('resource_not_found', 'copilot_prompt_not_found', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BlobQuotaExceeded extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('quota_exceeded', 'blob_quota_exceeded', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemberQuotaExceeded extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('quota_exceeded', 'member_quota_exceeded', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CopilotQuotaExceeded extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('quota_exceeded', 'copilot_quota_exceeded', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class RuntimeConfigNotFoundDataType {
|
||||||
|
@Field() key!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RuntimeConfigNotFound extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: RuntimeConfigNotFoundDataType,
|
||||||
|
message?: string | ((args: RuntimeConfigNotFoundDataType) => string)
|
||||||
|
) {
|
||||||
|
super('resource_not_found', 'runtime_config_not_found', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
class InvalidRuntimeConfigTypeDataType {
|
||||||
|
@Field() key!: string;
|
||||||
|
@Field() want!: string;
|
||||||
|
@Field() get!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidRuntimeConfigType extends UserFriendlyError {
|
||||||
|
constructor(
|
||||||
|
args: InvalidRuntimeConfigTypeDataType,
|
||||||
|
message?: string | ((args: InvalidRuntimeConfigTypeDataType) => string)
|
||||||
|
) {
|
||||||
|
super('invalid_input', 'invalid_runtime_config_type', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MailerServiceIsNotConfigured extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('internal_server_error', 'mailer_service_is_not_configured', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export enum ErrorNames {
|
||||||
|
INTERNAL_SERVER_ERROR,
|
||||||
|
TOO_MANY_REQUEST,
|
||||||
|
USER_NOT_FOUND,
|
||||||
|
USER_AVATAR_NOT_FOUND,
|
||||||
|
EMAIL_ALREADY_USED,
|
||||||
|
SAME_EMAIL_PROVIDED,
|
||||||
|
WRONG_SIGN_IN_CREDENTIALS,
|
||||||
|
UNKNOWN_OAUTH_PROVIDER,
|
||||||
|
OAUTH_STATE_EXPIRED,
|
||||||
|
INVALID_OAUTH_CALLBACK_STATE,
|
||||||
|
MISSING_OAUTH_QUERY_PARAMETER,
|
||||||
|
OAUTH_ACCOUNT_ALREADY_CONNECTED,
|
||||||
|
INVALID_EMAIL,
|
||||||
|
INVALID_PASSWORD_LENGTH,
|
||||||
|
WRONG_SIGN_IN_METHOD,
|
||||||
|
EARLY_ACCESS_REQUIRED,
|
||||||
|
SIGN_UP_FORBIDDEN,
|
||||||
|
EMAIL_TOKEN_NOT_FOUND,
|
||||||
|
INVALID_EMAIL_TOKEN,
|
||||||
|
AUTHENTICATION_REQUIRED,
|
||||||
|
ACTION_FORBIDDEN,
|
||||||
|
ACCESS_DENIED,
|
||||||
|
EMAIL_VERIFICATION_REQUIRED,
|
||||||
|
WORKSPACE_NOT_FOUND,
|
||||||
|
NOT_IN_WORKSPACE,
|
||||||
|
WORKSPACE_ACCESS_DENIED,
|
||||||
|
WORKSPACE_OWNER_NOT_FOUND,
|
||||||
|
CANT_CHANGE_WORKSPACE_OWNER,
|
||||||
|
DOC_NOT_FOUND,
|
||||||
|
DOC_ACCESS_DENIED,
|
||||||
|
VERSION_REJECTED,
|
||||||
|
INVALID_HISTORY_TIMESTAMP,
|
||||||
|
DOC_HISTORY_NOT_FOUND,
|
||||||
|
BLOB_NOT_FOUND,
|
||||||
|
EXPECT_TO_PUBLISH_PAGE,
|
||||||
|
EXPECT_TO_REVOKE_PUBLIC_PAGE,
|
||||||
|
PAGE_IS_NOT_PUBLIC,
|
||||||
|
FAILED_TO_CHECKOUT,
|
||||||
|
SUBSCRIPTION_ALREADY_EXISTS,
|
||||||
|
SUBSCRIPTION_NOT_EXISTS,
|
||||||
|
SUBSCRIPTION_HAS_BEEN_CANCELED,
|
||||||
|
SUBSCRIPTION_EXPIRED,
|
||||||
|
SAME_SUBSCRIPTION_RECURRING,
|
||||||
|
CUSTOMER_PORTAL_CREATE_FAILED,
|
||||||
|
SUBSCRIPTION_PLAN_NOT_FOUND,
|
||||||
|
COPILOT_SESSION_NOT_FOUND,
|
||||||
|
COPILOT_SESSION_DELETED,
|
||||||
|
NO_COPILOT_PROVIDER_AVAILABLE,
|
||||||
|
COPILOT_FAILED_TO_GENERATE_TEXT,
|
||||||
|
COPILOT_FAILED_TO_CREATE_MESSAGE,
|
||||||
|
UNSPLASH_IS_NOT_CONFIGURED,
|
||||||
|
COPILOT_ACTION_TAKEN,
|
||||||
|
COPILOT_MESSAGE_NOT_FOUND,
|
||||||
|
COPILOT_PROMPT_NOT_FOUND,
|
||||||
|
BLOB_QUOTA_EXCEEDED,
|
||||||
|
MEMBER_QUOTA_EXCEEDED,
|
||||||
|
COPILOT_QUOTA_EXCEEDED,
|
||||||
|
RUNTIME_CONFIG_NOT_FOUND,
|
||||||
|
INVALID_RUNTIME_CONFIG_TYPE,
|
||||||
|
MAILER_SERVICE_IS_NOT_CONFIGURED,
|
||||||
|
}
|
||||||
|
registerEnumType(ErrorNames, {
|
||||||
|
name: 'ErrorNames',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ErrorDataUnionType = createUnionType({
|
||||||
|
name: 'ErrorDataUnion',
|
||||||
|
types: () =>
|
||||||
|
[
|
||||||
|
UnknownOauthProviderDataType,
|
||||||
|
MissingOauthQueryParameterDataType,
|
||||||
|
InvalidPasswordLengthDataType,
|
||||||
|
WorkspaceNotFoundDataType,
|
||||||
|
NotInWorkspaceDataType,
|
||||||
|
WorkspaceAccessDeniedDataType,
|
||||||
|
WorkspaceOwnerNotFoundDataType,
|
||||||
|
DocNotFoundDataType,
|
||||||
|
DocAccessDeniedDataType,
|
||||||
|
VersionRejectedDataType,
|
||||||
|
InvalidHistoryTimestampDataType,
|
||||||
|
DocHistoryNotFoundDataType,
|
||||||
|
BlobNotFoundDataType,
|
||||||
|
SubscriptionAlreadyExistsDataType,
|
||||||
|
SubscriptionNotExistsDataType,
|
||||||
|
SameSubscriptionRecurringDataType,
|
||||||
|
SubscriptionPlanNotFoundDataType,
|
||||||
|
CopilotPromptNotFoundDataType,
|
||||||
|
RuntimeConfigNotFoundDataType,
|
||||||
|
InvalidRuntimeConfigTypeDataType,
|
||||||
|
] as const,
|
||||||
|
});
|
||||||
@@ -1,2 +1,44 @@
|
|||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { Args, Query, Resolver } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { Config } from '../config';
|
||||||
|
import { generateUserFriendlyErrors } from './def';
|
||||||
|
import { ActionForbidden, ErrorDataUnionType, ErrorNames } from './errors.gen';
|
||||||
|
|
||||||
|
@Resolver(() => ErrorDataUnionType)
|
||||||
|
class ErrorResolver {
|
||||||
|
// only exists for type registering
|
||||||
|
@Query(() => ErrorDataUnionType)
|
||||||
|
error(@Args({ name: 'name', type: () => ErrorNames }) _name: ErrorNames) {
|
||||||
|
throw new ActionForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [ErrorResolver],
|
||||||
|
})
|
||||||
|
export class ErrorModule implements OnModuleInit {
|
||||||
|
logger = new Logger('ErrorModule');
|
||||||
|
constructor(private readonly config: Config) {}
|
||||||
|
onModuleInit() {
|
||||||
|
if (!this.config.node.dev) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.log('Generating UserFriendlyError classes');
|
||||||
|
const def = generateUserFriendlyErrors();
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(fileURLToPath(import.meta.url), '../errors.gen.ts'),
|
||||||
|
def
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { UserFriendlyError } from './def';
|
||||||
|
export * from './errors.gen';
|
||||||
export * from './payment-required';
|
export * from './payment-required';
|
||||||
export * from './too-many-requests';
|
export * from './too-many-requests';
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import './config';
|
import './config';
|
||||||
|
|
||||||
|
import { STATUS_CODES } from 'node:http';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import type { ApolloDriverConfig } from '@nestjs/apollo';
|
import type { ApolloDriverConfig } from '@nestjs/apollo';
|
||||||
import { ApolloDriver } from '@nestjs/apollo';
|
import { ApolloDriver } from '@nestjs/apollo';
|
||||||
import { Global, HttpException, HttpStatus, Module } from '@nestjs/common';
|
import { Global, HttpStatus, Module } from '@nestjs/common';
|
||||||
import { GraphQLModule } from '@nestjs/graphql';
|
import { GraphQLModule } from '@nestjs/graphql';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { GraphQLError } from 'graphql';
|
import { GraphQLError } from 'graphql';
|
||||||
|
|
||||||
import { Config } from '../config';
|
import { Config } from '../config';
|
||||||
|
import { UserFriendlyError } from '../error';
|
||||||
import { GQLLoggerPlugin } from './logger-plugin';
|
import { GQLLoggerPlugin } from './logger-plugin';
|
||||||
|
|
||||||
export type GraphqlContext = {
|
export type GraphqlContext = {
|
||||||
@@ -57,25 +59,20 @@ export type GraphqlContext = {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
error instanceof GraphQLError &&
|
error instanceof GraphQLError &&
|
||||||
error.originalError instanceof HttpException
|
error.originalError instanceof UserFriendlyError
|
||||||
) {
|
) {
|
||||||
const statusCode = error.originalError.getStatus();
|
// @ts-expect-error allow assign
|
||||||
const statusName = HttpStatus[statusCode];
|
formattedError.extensions = error.originalError.json();
|
||||||
|
formattedError.extensions.stacktrace = error.originalError.stack;
|
||||||
// originally be 'INTERNAL_SERVER_ERROR'
|
|
||||||
formattedError.extensions['code'] = statusCode;
|
|
||||||
formattedError.extensions['status'] = statusName;
|
|
||||||
delete formattedError.extensions['originalError'];
|
|
||||||
|
|
||||||
return formattedError;
|
return formattedError;
|
||||||
} else {
|
} else {
|
||||||
// @ts-expect-error allow assign
|
// @ts-expect-error allow assign
|
||||||
formattedError.message = 'Internal Server Error';
|
formattedError.message = 'Internal Server Error';
|
||||||
|
|
||||||
formattedError.extensions['code'] =
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR;
|
|
||||||
formattedError.extensions['status'] =
|
formattedError.extensions['status'] =
|
||||||
HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR];
|
HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
formattedError.extensions['code'] =
|
||||||
|
STATUS_CODES[HttpStatus.INTERNAL_SERVER_ERROR];
|
||||||
}
|
}
|
||||||
|
|
||||||
return formattedError;
|
return formattedError;
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import {
|
|||||||
GraphQLRequestListener,
|
GraphQLRequestListener,
|
||||||
} from '@apollo/server';
|
} from '@apollo/server';
|
||||||
import { Plugin } from '@nestjs/apollo';
|
import { Plugin } from '@nestjs/apollo';
|
||||||
import { HttpException, Logger } from '@nestjs/common';
|
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
|
||||||
import { metrics } from '../metrics/metrics';
|
import { metrics } from '../metrics/metrics';
|
||||||
|
import { mapAnyError } from '../nestjs';
|
||||||
|
|
||||||
export interface RequestContext {
|
export interface RequestContext {
|
||||||
req: Express.Request & {
|
req: Express.Request & {
|
||||||
@@ -17,8 +17,6 @@ export interface RequestContext {
|
|||||||
|
|
||||||
@Plugin()
|
@Plugin()
|
||||||
export class GQLLoggerPlugin implements ApolloServerPlugin {
|
export class GQLLoggerPlugin implements ApolloServerPlugin {
|
||||||
protected logger = new Logger(GQLLoggerPlugin.name);
|
|
||||||
|
|
||||||
requestDidStart(
|
requestDidStart(
|
||||||
ctx: GraphQLRequestContext<RequestContext>
|
ctx: GraphQLRequestContext<RequestContext>
|
||||||
): Promise<GraphQLRequestListener<GraphQLRequestContext<RequestContext>>> {
|
): Promise<GraphQLRequestListener<GraphQLRequestContext<RequestContext>>> {
|
||||||
@@ -39,30 +37,15 @@ export class GQLLoggerPlugin implements ApolloServerPlugin {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
didEncounterErrors: ctx => {
|
didEncounterErrors: ctx => {
|
||||||
metrics.gql.counter('query_error_counter').add(1, { operation });
|
ctx.errors.forEach(gqlErr => {
|
||||||
|
const error = mapAnyError(
|
||||||
|
gqlErr.originalError ? gqlErr.originalError : gqlErr
|
||||||
|
);
|
||||||
|
error.log('GraphQL');
|
||||||
|
|
||||||
ctx.errors.forEach(err => {
|
metrics.gql
|
||||||
// only log non-user errors
|
.counter('query_error_counter')
|
||||||
let msg: string | undefined;
|
.add(1, { operation, code: error.status });
|
||||||
|
|
||||||
if (!err.originalError) {
|
|
||||||
msg = err.toString();
|
|
||||||
} else {
|
|
||||||
const originalError = err.originalError;
|
|
||||||
|
|
||||||
// do not log client errors, and put more information in the error extensions.
|
|
||||||
if (!(originalError instanceof HttpException)) {
|
|
||||||
if (originalError.cause && originalError.cause instanceof Error) {
|
|
||||||
msg = originalError.cause.stack ?? originalError.cause.message;
|
|
||||||
} else {
|
|
||||||
msg = originalError.stack ?? originalError.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg) {
|
|
||||||
this.logger.error('GraphQL Unhandled Error', msg);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ export { MailService } from './mailer';
|
|||||||
export { CallCounter, CallTimer, metrics } from './metrics';
|
export { CallCounter, CallTimer, metrics } from './metrics';
|
||||||
export { type ILocker, Lock, Locker, MutexService } from './mutex';
|
export { type ILocker, Lock, Locker, MutexService } from './mutex';
|
||||||
export {
|
export {
|
||||||
|
GatewayErrorWrapper,
|
||||||
getOptionalModuleMetadata,
|
getOptionalModuleMetadata,
|
||||||
GlobalExceptionFilter,
|
GlobalExceptionFilter,
|
||||||
|
mapAnyError,
|
||||||
|
mapSseError,
|
||||||
OptionalModule,
|
OptionalModule,
|
||||||
} from './nestjs';
|
} from './nestjs';
|
||||||
export type { PrismaTransaction } from './prisma';
|
export type { PrismaTransaction } from './prisma';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||||
|
|
||||||
import { Config } from '../config';
|
import { Config } from '../config';
|
||||||
|
import { MailerServiceIsNotConfigured } from '../error';
|
||||||
import { URLHelper } from '../helpers';
|
import { URLHelper } from '../helpers';
|
||||||
import type { MailerService, Options } from './mailer';
|
import type { MailerService, Options } from './mailer';
|
||||||
import { MAILER_SERVICE } from './mailer';
|
import { MAILER_SERVICE } from './mailer';
|
||||||
@@ -15,7 +16,7 @@ export class MailService {
|
|||||||
|
|
||||||
async sendMail(options: Options) {
|
async sendMail(options: Options) {
|
||||||
if (!this.mailer) {
|
if (!this.mailer) {
|
||||||
throw new Error('Mailer service is not configured.');
|
throw new MailerServiceIsNotConfigured();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.mailer.sendMail({
|
return this.mailer.sendMail({
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export type KnownMetricScopes =
|
|||||||
| 'jwst'
|
| 'jwst'
|
||||||
| 'auth'
|
| 'auth'
|
||||||
| 'controllers'
|
| 'controllers'
|
||||||
| 'doc';
|
| 'doc'
|
||||||
|
| 'sse';
|
||||||
|
|
||||||
const metricCreators: MetricCreators = {
|
const metricCreators: MetricCreators = {
|
||||||
counter(meter: Meter, name: string, opts?: MetricOptions) {
|
counter(meter: Meter, name: string, opts?: MetricOptions) {
|
||||||
|
|||||||
@@ -1,25 +1,87 @@
|
|||||||
import { ArgumentsHost, Catch, HttpException } from '@nestjs/common';
|
import { ArgumentsHost, Catch, Logger } from '@nestjs/common';
|
||||||
import { BaseExceptionFilter } from '@nestjs/core';
|
import { BaseExceptionFilter } from '@nestjs/core';
|
||||||
import { GqlContextType } from '@nestjs/graphql';
|
import { GqlContextType } from '@nestjs/graphql';
|
||||||
|
import { ThrottlerException } from '@nestjs/throttler';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
InternalServerError,
|
||||||
|
TooManyRequest,
|
||||||
|
UserFriendlyError,
|
||||||
|
} from '../error';
|
||||||
|
import { metrics } from '../metrics';
|
||||||
|
|
||||||
|
export function mapAnyError(error: any): UserFriendlyError {
|
||||||
|
if (error instanceof UserFriendlyError) {
|
||||||
|
return error;
|
||||||
|
} else if (error instanceof ThrottlerException) {
|
||||||
|
return new TooManyRequest();
|
||||||
|
} else {
|
||||||
|
const e = new InternalServerError();
|
||||||
|
e.cause = error;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class GlobalExceptionFilter extends BaseExceptionFilter {
|
export class GlobalExceptionFilter extends BaseExceptionFilter {
|
||||||
|
logger = new Logger('GlobalExceptionFilter');
|
||||||
override catch(exception: Error, host: ArgumentsHost) {
|
override catch(exception: Error, host: ArgumentsHost) {
|
||||||
|
const error = mapAnyError(exception);
|
||||||
// with useGlobalFilters, the context is always HTTP
|
// with useGlobalFilters, the context is always HTTP
|
||||||
|
|
||||||
if (host.getType<GqlContextType>() === 'graphql') {
|
if (host.getType<GqlContextType>() === 'graphql') {
|
||||||
// let Graphql LoggerPlugin handle it
|
// let Graphql LoggerPlugin handle it
|
||||||
// see '../graphql/logger-plugin.ts'
|
// see '../graphql/logger-plugin.ts'
|
||||||
throw exception;
|
throw error;
|
||||||
} else {
|
} else {
|
||||||
if (exception instanceof HttpException) {
|
error.log('HTTP');
|
||||||
|
metrics.controllers.counter('error').add(1, { status: error.status });
|
||||||
const res = host.switchToHttp().getResponse<Response>();
|
const res = host.switchToHttp().getResponse<Response>();
|
||||||
res.status(exception.getStatus()).send(exception.getResponse());
|
res.status(error.status).send(error.json());
|
||||||
return;
|
return;
|
||||||
} else {
|
|
||||||
super.catch(exception, host);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const GatewayErrorWrapper = (event: string): MethodDecorator => {
|
||||||
|
// @ts-expect-error allow
|
||||||
|
return (
|
||||||
|
_target,
|
||||||
|
_key,
|
||||||
|
desc: TypedPropertyDescriptor<(...args: any[]) => any>
|
||||||
|
) => {
|
||||||
|
const originalMethod = desc.value;
|
||||||
|
if (!originalMethod) {
|
||||||
|
return desc;
|
||||||
|
}
|
||||||
|
|
||||||
|
desc.value = async function (...args: any[]) {
|
||||||
|
try {
|
||||||
|
return await originalMethod.apply(this, args);
|
||||||
|
} catch (error) {
|
||||||
|
const mappedError = mapAnyError(error);
|
||||||
|
mappedError.log('Websocket');
|
||||||
|
metrics.socketio
|
||||||
|
.counter('error')
|
||||||
|
.add(1, { event, status: mappedError.status });
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: mappedError.json(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return desc;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapSseError(originalError: any) {
|
||||||
|
const error = mapAnyError(originalError);
|
||||||
|
error.log('Sse');
|
||||||
|
metrics.sse.counter('error').add(1, { status: error.status });
|
||||||
|
return of({
|
||||||
|
type: 'error' as const,
|
||||||
|
data: error.json(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
|
||||||
InternalServerErrorException,
|
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
@@ -23,14 +19,21 @@ import {
|
|||||||
merge,
|
merge,
|
||||||
mergeMap,
|
mergeMap,
|
||||||
Observable,
|
Observable,
|
||||||
of,
|
|
||||||
switchMap,
|
switchMap,
|
||||||
toArray,
|
toArray,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
import { Public } from '../../core/auth';
|
import { Public } from '../../core/auth';
|
||||||
import { CurrentUser } from '../../core/auth/current-user';
|
import { CurrentUser } from '../../core/auth/current-user';
|
||||||
import { Config } from '../../fundamentals';
|
import {
|
||||||
|
BlobNotFound,
|
||||||
|
Config,
|
||||||
|
CopilotFailedToGenerateText,
|
||||||
|
CopilotSessionNotFound,
|
||||||
|
mapSseError,
|
||||||
|
NoCopilotProviderAvailable,
|
||||||
|
UnsplashIsNotConfigured,
|
||||||
|
} from '../../fundamentals';
|
||||||
import { CopilotProviderService } from './providers';
|
import { CopilotProviderService } from './providers';
|
||||||
import { ChatSession, ChatSessionService } from './session';
|
import { ChatSession, ChatSessionService } from './session';
|
||||||
import { CopilotStorage } from './storage';
|
import { CopilotStorage } from './storage';
|
||||||
@@ -40,7 +43,7 @@ import { CopilotWorkflowService } from './workflow';
|
|||||||
export interface ChatEvent {
|
export interface ChatEvent {
|
||||||
type: 'attachment' | 'message' | 'error';
|
type: 'attachment' | 'message' | 'error';
|
||||||
id?: string;
|
id?: string;
|
||||||
data: string;
|
data: string | object;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckResult = {
|
type CheckResult = {
|
||||||
@@ -68,7 +71,7 @@ export class CopilotController {
|
|||||||
await this.chatSession.checkQuota(userId);
|
await this.chatSession.checkQuota(userId);
|
||||||
const session = await this.chatSession.get(sessionId);
|
const session = await this.chatSession.get(sessionId);
|
||||||
if (!session || session.config.userId !== userId) {
|
if (!session || session.config.userId !== userId) {
|
||||||
throw new BadRequestException('Session not found');
|
throw new CopilotSessionNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const ret: CheckResult = { model: session.model };
|
const ret: CheckResult = { model: session.model };
|
||||||
@@ -104,7 +107,7 @@ export class CopilotController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
throw new InternalServerErrorException('No provider available');
|
throw new NoCopilotProviderAvailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
return provider;
|
return provider;
|
||||||
@@ -116,7 +119,7 @@ export class CopilotController {
|
|||||||
): Promise<ChatSession> {
|
): Promise<ChatSession> {
|
||||||
const session = await this.chatSession.get(sessionId);
|
const session = await this.chatSession.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new BadRequestException('Session not found');
|
throw new CopilotSessionNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageId) {
|
if (messageId) {
|
||||||
@@ -148,20 +151,6 @@ export class CopilotController {
|
|||||||
return num;
|
return num;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleError(err: any) {
|
|
||||||
if (err instanceof Error) {
|
|
||||||
const ret = {
|
|
||||||
message: err.message,
|
|
||||||
status: (err as any).status,
|
|
||||||
};
|
|
||||||
if (err instanceof HttpException) {
|
|
||||||
ret.status = err.getStatus();
|
|
||||||
}
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('/chat/:sessionId')
|
@Get('/chat/:sessionId')
|
||||||
async chat(
|
async chat(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@@ -200,9 +189,7 @@ export class CopilotController {
|
|||||||
|
|
||||||
return content;
|
return content;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
throw new InternalServerErrorException(
|
throw new CopilotFailedToGenerateText(e.message);
|
||||||
e.message || "Couldn't generate text"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,18 +240,10 @@ export class CopilotController {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
catchError(err =>
|
catchError(mapSseError)
|
||||||
of({
|
|
||||||
type: 'error' as const,
|
|
||||||
data: this.handleError(err),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return of({
|
return mapSseError(err);
|
||||||
type: 'error' as const,
|
|
||||||
data: this.handleError(err),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,18 +297,10 @@ export class CopilotController {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
catchError(err =>
|
catchError(mapSseError)
|
||||||
of({
|
|
||||||
type: 'error' as const,
|
|
||||||
data: this.handleError(err),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return of({
|
return mapSseError(err);
|
||||||
type: 'error' as const,
|
|
||||||
data: this.handleError(err),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +327,7 @@ export class CopilotController {
|
|||||||
model
|
model
|
||||||
);
|
);
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
throw new InternalServerErrorException('No provider available');
|
throw new NoCopilotProviderAvailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await this.appendSessionMessage(sessionId, messageId);
|
const session = await this.appendSessionMessage(sessionId, messageId);
|
||||||
@@ -402,18 +373,10 @@ export class CopilotController {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
catchError(err =>
|
catchError(mapSseError)
|
||||||
of({
|
|
||||||
type: 'error' as const,
|
|
||||||
data: this.handleError(err),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return of({
|
return mapSseError(err);
|
||||||
type: 'error' as const,
|
|
||||||
data: this.handleError(err),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,7 +388,7 @@ export class CopilotController {
|
|||||||
) {
|
) {
|
||||||
const { unsplashKey } = this.config.plugins.copilot || {};
|
const { unsplashKey } = this.config.plugins.copilot || {};
|
||||||
if (!unsplashKey) {
|
if (!unsplashKey) {
|
||||||
throw new InternalServerErrorException('Unsplash key is not configured');
|
throw new UnsplashIsNotConfigured();
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = new URLSearchParams(params);
|
const query = new URLSearchParams(params);
|
||||||
@@ -458,9 +421,10 @@ export class CopilotController {
|
|||||||
const { body, metadata } = await this.storage.get(userId, workspaceId, key);
|
const { body, metadata } = await this.storage.get(userId, workspaceId, key);
|
||||||
|
|
||||||
if (!body) {
|
if (!body) {
|
||||||
throw new NotFoundException(
|
throw new BlobNotFound({
|
||||||
`Blob not found in ${userId}'s workspace ${workspaceId}: ${key}`
|
workspaceId,
|
||||||
);
|
blobId: key,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// metadata should always exists if body is not null
|
// metadata should always exists if body is not null
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Field,
|
Field,
|
||||||
@@ -23,6 +23,7 @@ import { Admin } from '../../core/common';
|
|||||||
import { UserType } from '../../core/user';
|
import { UserType } from '../../core/user';
|
||||||
import { PermissionService } from '../../core/workspaces/permission';
|
import { PermissionService } from '../../core/workspaces/permission';
|
||||||
import {
|
import {
|
||||||
|
CopilotFailedToCreateMessage,
|
||||||
FileUpload,
|
FileUpload,
|
||||||
MutexService,
|
MutexService,
|
||||||
Throttle,
|
Throttle,
|
||||||
@@ -201,8 +202,6 @@ export class CopilotType {
|
|||||||
@Throttle()
|
@Throttle()
|
||||||
@Resolver(() => CopilotType)
|
@Resolver(() => CopilotType)
|
||||||
export class CopilotResolver {
|
export class CopilotResolver {
|
||||||
private readonly logger = new Logger(CopilotResolver.name);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly permissions: PermissionService,
|
private readonly permissions: PermissionService,
|
||||||
private readonly mutex: MutexService,
|
private readonly mutex: MutexService,
|
||||||
@@ -385,8 +384,7 @@ export class CopilotResolver {
|
|||||||
try {
|
try {
|
||||||
return await this.chatSession.createMessage(options);
|
return await this.chatSession.createMessage(options);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.logger.error(`Failed to create chat message: ${e.message}`);
|
throw new CopilotFailedToCreateMessage(e.message);
|
||||||
throw new Error('Failed to create chat message');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import { AiPromptRole, PrismaClient } from '@prisma/client';
|
|||||||
|
|
||||||
import { FeatureManagementService } from '../../core/features';
|
import { FeatureManagementService } from '../../core/features';
|
||||||
import { QuotaService } from '../../core/quota';
|
import { QuotaService } from '../../core/quota';
|
||||||
import { PaymentRequiredException } from '../../fundamentals';
|
import {
|
||||||
|
CopilotActionTaken,
|
||||||
|
CopilotMessageNotFound,
|
||||||
|
CopilotPromptNotFound,
|
||||||
|
CopilotQuotaExceeded,
|
||||||
|
CopilotSessionDeleted,
|
||||||
|
CopilotSessionNotFound,
|
||||||
|
} from '../../fundamentals';
|
||||||
import { ChatMessageCache } from './message';
|
import { ChatMessageCache } from './message';
|
||||||
import { PromptService } from './prompt';
|
import { PromptService } from './prompt';
|
||||||
import {
|
import {
|
||||||
@@ -58,7 +65,7 @@ export class ChatSession implements AsyncDisposable {
|
|||||||
this.state.messages.length > 0 &&
|
this.state.messages.length > 0 &&
|
||||||
message.role === 'user'
|
message.role === 'user'
|
||||||
) {
|
) {
|
||||||
throw new Error('Action has been taken, no more messages allowed');
|
throw new CopilotActionTaken();
|
||||||
}
|
}
|
||||||
this.state.messages.push(message);
|
this.state.messages.push(message);
|
||||||
this.stashMessageCount += 1;
|
this.stashMessageCount += 1;
|
||||||
@@ -74,7 +81,7 @@ export class ChatSession implements AsyncDisposable {
|
|||||||
async getMessageById(messageId: string) {
|
async getMessageById(messageId: string) {
|
||||||
const message = await this.messageCache.get(messageId);
|
const message = await this.messageCache.get(messageId);
|
||||||
if (!message || message.sessionId !== this.state.sessionId) {
|
if (!message || message.sessionId !== this.state.sessionId) {
|
||||||
throw new Error(`Message not found: ${messageId}`);
|
throw new CopilotMessageNotFound();
|
||||||
}
|
}
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
@@ -82,7 +89,7 @@ export class ChatSession implements AsyncDisposable {
|
|||||||
async pushByMessageId(messageId: string) {
|
async pushByMessageId(messageId: string) {
|
||||||
const message = await this.messageCache.get(messageId);
|
const message = await this.messageCache.get(messageId);
|
||||||
if (!message || message.sessionId !== this.state.sessionId) {
|
if (!message || message.sessionId !== this.state.sessionId) {
|
||||||
throw new Error(`Message not found: ${messageId}`);
|
throw new CopilotMessageNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.push({
|
this.push({
|
||||||
@@ -196,7 +203,7 @@ export class ChatSessionService {
|
|||||||
},
|
},
|
||||||
select: { id: true, deletedAt: true },
|
select: { id: true, deletedAt: true },
|
||||||
})) || {};
|
})) || {};
|
||||||
if (deletedAt) throw new Error(`Session is deleted: ${id}`);
|
if (deletedAt) throw new CopilotSessionDeleted();
|
||||||
if (id) sessionId = id;
|
if (id) sessionId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +281,8 @@ export class ChatSessionService {
|
|||||||
.then(async session => {
|
.then(async session => {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const prompt = await this.prompt.get(session.promptName);
|
const prompt = await this.prompt.get(session.promptName);
|
||||||
if (!prompt) throw new Error(`Prompt not found: ${session.promptName}`);
|
if (!prompt)
|
||||||
|
throw new CopilotPromptNotFound({ name: session.promptName });
|
||||||
|
|
||||||
const messages = ChatMessageSchema.array().safeParse(session.messages);
|
const messages = ChatMessageSchema.array().safeParse(session.messages);
|
||||||
|
|
||||||
@@ -300,7 +308,7 @@ export class ChatSessionService {
|
|||||||
})
|
})
|
||||||
.then(session => session?.id);
|
.then(session => session?.id);
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new Error(`Session not found: ${sessionId}`);
|
throw new CopilotSessionNotFound();
|
||||||
}
|
}
|
||||||
const ids = await tx.aiSessionMessage
|
const ids = await tx.aiSessionMessage
|
||||||
.findMany({
|
.findMany({
|
||||||
@@ -412,7 +420,7 @@ export class ChatSessionService {
|
|||||||
if (ret.success) {
|
if (ret.success) {
|
||||||
const prompt = await this.prompt.get(promptName);
|
const prompt = await this.prompt.get(promptName);
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
throw new Error(`Prompt not found: ${promptName}`);
|
throw new CopilotPromptNotFound({ name: promptName });
|
||||||
}
|
}
|
||||||
|
|
||||||
// render system prompt
|
// render system prompt
|
||||||
@@ -471,9 +479,7 @@ export class ChatSessionService {
|
|||||||
async checkQuota(userId: string) {
|
async checkQuota(userId: string) {
|
||||||
const { limit, used } = await this.getQuota(userId);
|
const { limit, used } = await this.getQuota(userId);
|
||||||
if (limit && Number.isFinite(limit) && used >= limit) {
|
if (limit && Number.isFinite(limit) && used >= limit) {
|
||||||
throw new PaymentRequiredException(
|
throw new CopilotQuotaExceeded();
|
||||||
`You have reached the limit of actions in this workspace, please upgrade your plan.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,7 +488,7 @@ export class ChatSessionService {
|
|||||||
const prompt = await this.prompt.get(options.promptName);
|
const prompt = await this.prompt.get(options.promptName);
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
this.logger.error(`Prompt not found: ${options.promptName}`);
|
this.logger.error(`Prompt not found: ${options.promptName}`);
|
||||||
throw new Error('Prompt not found');
|
throw new CopilotPromptNotFound({ name: options.promptName });
|
||||||
}
|
}
|
||||||
return await this.setSession({
|
return await this.setSession({
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
import { Injectable, PayloadTooLargeException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { QuotaManagementService } from '../../core/quota';
|
import { QuotaManagementService } from '../../core/quota';
|
||||||
import {
|
import {
|
||||||
type BlobInputType,
|
type BlobInputType,
|
||||||
|
BlobQuotaExceeded,
|
||||||
Config,
|
Config,
|
||||||
type FileUpload,
|
type FileUpload,
|
||||||
type StorageProvider,
|
type StorageProvider,
|
||||||
@@ -54,9 +55,7 @@ export class CopilotStorage {
|
|||||||
const checkExceeded = await this.quota.getQuotaCalculator(userId);
|
const checkExceeded = await this.quota.getQuotaCalculator(userId);
|
||||||
|
|
||||||
if (checkExceeded(0)) {
|
if (checkExceeded(0)) {
|
||||||
throw new PayloadTooLargeException(
|
throw new BlobQuotaExceeded();
|
||||||
'Storage or blob size limit exceeded.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const buffer = await new Promise<Buffer>((resolve, reject) => {
|
const buffer = await new Promise<Buffer>((resolve, reject) => {
|
||||||
const stream = blob.createReadStream();
|
const stream = blob.createReadStream();
|
||||||
@@ -67,9 +66,7 @@ export class CopilotStorage {
|
|||||||
// check size after receive each chunk to avoid unnecessary memory usage
|
// check size after receive each chunk to avoid unnecessary memory usage
|
||||||
const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0);
|
const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0);
|
||||||
if (checkExceeded(bufferSize)) {
|
if (checkExceeded(bufferSize)) {
|
||||||
reject(
|
reject(new BlobQuotaExceeded());
|
||||||
new PayloadTooLargeException('Storage or blob size limit exceeded.')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
stream.on('error', reject);
|
stream.on('error', reject);
|
||||||
@@ -77,7 +74,7 @@ export class CopilotStorage {
|
|||||||
const buffer = Buffer.concat(chunks);
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
if (checkExceeded(buffer.length)) {
|
if (checkExceeded(buffer.length)) {
|
||||||
reject(new PayloadTooLargeException('Storage limit exceeded.'));
|
reject(new BlobQuotaExceeded());
|
||||||
} else {
|
} else {
|
||||||
resolve(buffer);
|
resolve(buffer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import {
|
import { Controller, Get, Query, Req, Res } from '@nestjs/common';
|
||||||
BadRequestException,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Query,
|
|
||||||
Req,
|
|
||||||
Res,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ConnectedAccount, PrismaClient } from '@prisma/client';
|
import { ConnectedAccount, PrismaClient } from '@prisma/client';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
import { AuthService, Public } from '../../core/auth';
|
import { AuthService, Public } from '../../core/auth';
|
||||||
import { UserService } from '../../core/user';
|
import { UserService } from '../../core/user';
|
||||||
import { URLHelper } from '../../fundamentals';
|
import {
|
||||||
|
InvalidOauthCallbackState,
|
||||||
|
MissingOauthQueryParameter,
|
||||||
|
OauthAccountAlreadyConnected,
|
||||||
|
OauthStateExpired,
|
||||||
|
UnknownOauthProvider,
|
||||||
|
URLHelper,
|
||||||
|
WrongSignInMethod,
|
||||||
|
} from '../../fundamentals';
|
||||||
import { OAuthProviderName } from './config';
|
import { OAuthProviderName } from './config';
|
||||||
import { OAuthAccount, Tokens } from './providers/def';
|
import { OAuthAccount, Tokens } from './providers/def';
|
||||||
import { OAuthProviderFactory } from './register';
|
import { OAuthProviderFactory } from './register';
|
||||||
@@ -35,12 +36,15 @@ export class OAuthController {
|
|||||||
@Query('provider') unknownProviderName: string,
|
@Query('provider') unknownProviderName: string,
|
||||||
@Query('redirect_uri') redirectUri?: string
|
@Query('redirect_uri') redirectUri?: string
|
||||||
) {
|
) {
|
||||||
|
if (!unknownProviderName) {
|
||||||
|
throw new MissingOauthQueryParameter({ name: 'provider' });
|
||||||
|
}
|
||||||
// @ts-expect-error safe
|
// @ts-expect-error safe
|
||||||
const providerName = OAuthProviderName[unknownProviderName];
|
const providerName = OAuthProviderName[unknownProviderName];
|
||||||
const provider = this.providerFactory.get(providerName);
|
const provider = this.providerFactory.get(providerName);
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
throw new BadRequestException('Invalid OAuth provider');
|
throw new UnknownOauthProvider({ name: unknownProviderName });
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = await this.oauth.saveOAuthState({
|
const state = await this.oauth.saveOAuthState({
|
||||||
@@ -60,29 +64,31 @@ export class OAuthController {
|
|||||||
@Query('state') stateStr?: string
|
@Query('state') stateStr?: string
|
||||||
) {
|
) {
|
||||||
if (!code) {
|
if (!code) {
|
||||||
throw new BadRequestException('Missing query parameter `code`');
|
throw new MissingOauthQueryParameter({ name: 'code' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stateStr) {
|
if (!stateStr) {
|
||||||
throw new BadRequestException('Invalid callback state parameter');
|
throw new MissingOauthQueryParameter({ name: 'state' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof stateStr !== 'string' || !this.oauth.isValidState(stateStr)) {
|
||||||
|
throw new InvalidOauthCallbackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = await this.oauth.getOAuthState(stateStr);
|
const state = await this.oauth.getOAuthState(stateStr);
|
||||||
|
|
||||||
if (!state) {
|
if (!state) {
|
||||||
throw new BadRequestException('OAuth state expired, please try again.');
|
throw new OauthStateExpired();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.provider) {
|
if (!state.provider) {
|
||||||
throw new BadRequestException(
|
throw new MissingOauthQueryParameter({ name: 'provider' });
|
||||||
'Missing callback state parameter `provider`'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = this.providerFactory.get(state.provider);
|
const provider = this.providerFactory.get(state.provider);
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
throw new BadRequestException('Invalid provider');
|
throw new UnknownOauthProvider({ name: state.provider ?? 'unknown' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await provider.getToken(code);
|
const tokens = await provider.getToken(code);
|
||||||
@@ -154,15 +160,9 @@ export class OAuthController {
|
|||||||
// we can't directly connect the external account with given email in sign in scenario for safety concern.
|
// we can't directly connect the external account with given email in sign in scenario for safety concern.
|
||||||
// let user manually connect in account sessions instead.
|
// let user manually connect in account sessions instead.
|
||||||
if (user.registered) {
|
if (user.registered) {
|
||||||
throw new BadRequestException(
|
throw new WrongSignInMethod();
|
||||||
'The account with provided email is not register in the same way.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.user.fulfillUser(externalAccount.email, {
|
|
||||||
emailVerifiedAt: new Date(),
|
|
||||||
registered: true,
|
|
||||||
});
|
|
||||||
await this.db.connectedAccount.create({
|
await this.db.connectedAccount.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -228,9 +228,7 @@ export class OAuthController {
|
|||||||
|
|
||||||
if (connectedUser) {
|
if (connectedUser) {
|
||||||
if (connectedUser.id !== user.id) {
|
if (connectedUser.id !== user.id) {
|
||||||
throw new BadRequestException(
|
throw new OauthAccountAlreadyConnected();
|
||||||
'The third-party account has already been connected to another user.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.db.connectedAccount.create({
|
await this.db.connectedAccount.create({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { Config, URLHelper } from '../../../fundamentals';
|
import { Config, URLHelper } from '../../../fundamentals';
|
||||||
import { OAuthProviderName } from '../config';
|
import { OAuthProviderName } from '../config';
|
||||||
@@ -39,7 +39,6 @@ export class GithubOAuthProvider extends AutoRegisteredOAuthProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getToken(code: string) {
|
async getToken(code: string) {
|
||||||
try {
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
'https://github.com/login/oauth/access_token',
|
'https://github.com/login/oauth/access_token',
|
||||||
{
|
{
|
||||||
@@ -71,16 +70,9 @@ export class GithubOAuthProvider extends AutoRegisteredOAuthProvider {
|
|||||||
}, ${JSON.stringify(await response.json())}`
|
}, ${JSON.stringify(await response.json())}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
throw new HttpException(
|
|
||||||
`Failed to get access_token, err: ${(e as Error).message}`,
|
|
||||||
HttpStatus.BAD_REQUEST
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUser(token: string) {
|
async getUser(token: string) {
|
||||||
try {
|
|
||||||
const response = await fetch('https://api.github.com/user', {
|
const response = await fetch('https://api.github.com/user', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -103,11 +95,5 @@ export class GithubOAuthProvider extends AutoRegisteredOAuthProvider {
|
|||||||
} ${await response.text()}`
|
} ${await response.text()}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
throw new HttpException(
|
|
||||||
`Failed to get user information, err: ${(e as Error).stack}`,
|
|
||||||
HttpStatus.BAD_REQUEST
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { Config, URLHelper } from '../../../fundamentals';
|
import { Config, URLHelper } from '../../../fundamentals';
|
||||||
import { OAuthProviderName } from '../config';
|
import { OAuthProviderName } from '../config';
|
||||||
@@ -44,7 +44,6 @@ export class GoogleOAuthProvider extends AutoRegisteredOAuthProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getToken(code: string) {
|
async getToken(code: string) {
|
||||||
try {
|
|
||||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: this.url.stringify({
|
body: this.url.stringify({
|
||||||
@@ -76,16 +75,9 @@ export class GoogleOAuthProvider extends AutoRegisteredOAuthProvider {
|
|||||||
}, ${JSON.stringify(await response.json())}`
|
}, ${JSON.stringify(await response.json())}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
throw new HttpException(
|
|
||||||
`Failed to get access_token, err: ${(e as Error).message}`,
|
|
||||||
HttpStatus.BAD_REQUEST
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUser(token: string) {
|
async getUser(token: string) {
|
||||||
try {
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
'https://www.googleapis.com/oauth2/v2/userinfo',
|
'https://www.googleapis.com/oauth2/v2/userinfo',
|
||||||
{
|
{
|
||||||
@@ -111,11 +103,5 @@ export class GoogleOAuthProvider extends AutoRegisteredOAuthProvider {
|
|||||||
} ${await response.text()}`
|
} ${await response.text()}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
throw new HttpException(
|
|
||||||
`Failed to get user information, err: ${(e as Error).stack}`,
|
|
||||||
HttpStatus.BAD_REQUEST
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
InternalServerErrorException,
|
|
||||||
OnModuleInit,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { Config, URLHelper } from '../../../fundamentals';
|
import { Config, URLHelper } from '../../../fundamentals';
|
||||||
@@ -44,6 +39,8 @@ const OIDCConfigurationSchema = z.object({
|
|||||||
|
|
||||||
type OIDCConfiguration = z.infer<typeof OIDCConfigurationSchema>;
|
type OIDCConfiguration = z.infer<typeof OIDCConfigurationSchema>;
|
||||||
|
|
||||||
|
const logger = new Logger('OIDCClient');
|
||||||
|
|
||||||
class OIDCClient {
|
class OIDCClient {
|
||||||
private static async fetch<T = any>(
|
private static async fetch<T = any>(
|
||||||
url: string,
|
url: string,
|
||||||
@@ -53,17 +50,8 @@ class OIDCClient {
|
|||||||
const response = await fetch(url, options);
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status >= 400 && response.status < 500) {
|
logger.error('Failed to fetch OIDC configuration', await response.json());
|
||||||
throw new BadRequestException(`Invalid OIDC configuration`, {
|
throw new Error(`Failed to configure client`);
|
||||||
cause: await response.json(),
|
|
||||||
description: response.statusText,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new InternalServerErrorException(`Failed to configure client`, {
|
|
||||||
cause: await response.json(),
|
|
||||||
description: response.statusText,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return verifier.parse(data);
|
return verifier.parse(data);
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export class OAuthService {
|
|||||||
private readonly cache: SessionCache
|
private readonly cache: SessionCache
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
isValidState(stateStr: string) {
|
||||||
|
return stateStr.length === 36;
|
||||||
|
}
|
||||||
|
|
||||||
async saveOAuthState(state: OAuthState) {
|
async saveOAuthState(state: OAuthState) {
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, state, {
|
await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, state, {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { BadGatewayException, ForbiddenException } from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Context,
|
Context,
|
||||||
@@ -19,7 +18,12 @@ import { groupBy } from 'lodash-es';
|
|||||||
|
|
||||||
import { CurrentUser, Public } from '../../core/auth';
|
import { CurrentUser, Public } from '../../core/auth';
|
||||||
import { UserType } from '../../core/user';
|
import { UserType } from '../../core/user';
|
||||||
import { Config, URLHelper } from '../../fundamentals';
|
import {
|
||||||
|
AccessDenied,
|
||||||
|
Config,
|
||||||
|
FailedToCheckout,
|
||||||
|
URLHelper,
|
||||||
|
} from '../../fundamentals';
|
||||||
import { decodeLookupKey, SubscriptionService } from './service';
|
import { decodeLookupKey, SubscriptionService } from './service';
|
||||||
import {
|
import {
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
@@ -227,7 +231,7 @@ export class SubscriptionResolver {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!session.url) {
|
if (!session.url) {
|
||||||
throw new BadGatewayException('Failed to create checkout session.');
|
throw new FailedToCheckout();
|
||||||
}
|
}
|
||||||
|
|
||||||
return session.url;
|
return session.url;
|
||||||
@@ -322,9 +326,7 @@ export class UserSubscriptionResolver {
|
|||||||
) {
|
) {
|
||||||
// allow admin to query other user's subscription
|
// allow admin to query other user's subscription
|
||||||
if (!ctx.isAdminQuery && me.id !== user.id) {
|
if (!ctx.isAdminQuery && me.id !== user.id) {
|
||||||
throw new ForbiddenException(
|
throw new AccessDenied();
|
||||||
'You are not allowed to access this subscription.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// @FIXME(@forehalo): should not mock any api for selfhosted server
|
// @FIXME(@forehalo): should not mock any api for selfhosted server
|
||||||
@@ -363,9 +365,7 @@ export class UserSubscriptionResolver {
|
|||||||
@Parent() user: User
|
@Parent() user: User
|
||||||
): Promise<UserSubscription[]> {
|
): Promise<UserSubscription[]> {
|
||||||
if (me.id !== user.id) {
|
if (me.id !== user.id) {
|
||||||
throw new ForbiddenException(
|
throw new AccessDenied();
|
||||||
'You are not allowed to access this subscription.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.db.userSubscription.findMany({
|
return this.db.userSubscription.findMany({
|
||||||
@@ -385,9 +385,7 @@ export class UserSubscriptionResolver {
|
|||||||
@Args('skip', { type: () => Int, nullable: true }) skip?: number
|
@Args('skip', { type: () => Int, nullable: true }) skip?: number
|
||||||
) {
|
) {
|
||||||
if (me.id !== user.id) {
|
if (me.id !== user.id) {
|
||||||
throw new ForbiddenException(
|
throw new AccessDenied();
|
||||||
'You are not allowed to access this invoices'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.db.userInvoice.findMany({
|
return this.db.userInvoice.findMany({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { OnEvent as RawOnEvent } from '@nestjs/event-emitter';
|
import { OnEvent as RawOnEvent } from '@nestjs/event-emitter';
|
||||||
import type {
|
import type {
|
||||||
Prisma,
|
Prisma,
|
||||||
@@ -14,7 +14,20 @@ import Stripe from 'stripe';
|
|||||||
|
|
||||||
import { CurrentUser } from '../../core/auth';
|
import { CurrentUser } from '../../core/auth';
|
||||||
import { EarlyAccessType, FeatureManagementService } from '../../core/features';
|
import { EarlyAccessType, FeatureManagementService } from '../../core/features';
|
||||||
import { Config, EventEmitter, OnEvent } from '../../fundamentals';
|
import {
|
||||||
|
ActionForbidden,
|
||||||
|
Config,
|
||||||
|
CustomerPortalCreateFailed,
|
||||||
|
EventEmitter,
|
||||||
|
OnEvent,
|
||||||
|
SameSubscriptionRecurring,
|
||||||
|
SubscriptionAlreadyExists,
|
||||||
|
SubscriptionExpired,
|
||||||
|
SubscriptionHasBeenCanceled,
|
||||||
|
SubscriptionNotExists,
|
||||||
|
SubscriptionPlanNotFound,
|
||||||
|
UserNotFound,
|
||||||
|
} from '../../fundamentals';
|
||||||
import { ScheduleManager } from './schedule';
|
import { ScheduleManager } from './schedule';
|
||||||
import {
|
import {
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
@@ -160,7 +173,7 @@ export class SubscriptionService {
|
|||||||
this.config.affine.canary &&
|
this.config.affine.canary &&
|
||||||
!this.features.isStaff(user.email)
|
!this.features.isStaff(user.email)
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException('You are not allowed to do this.');
|
throw new ActionForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSubscription = await this.db.userSubscription.findFirst({
|
const currentSubscription = await this.db.userSubscription.findFirst({
|
||||||
@@ -172,9 +185,7 @@ export class SubscriptionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (currentSubscription) {
|
if (currentSubscription) {
|
||||||
throw new BadRequestException(
|
throw new SubscriptionAlreadyExists({ plan });
|
||||||
`You've already subscribed to the ${plan} plan`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const customer = await this.getOrCreateCustomer(
|
const customer = await this.getOrCreateCustomer(
|
||||||
@@ -245,18 +256,16 @@ export class SubscriptionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new BadRequestException('Unknown user');
|
throw new UserNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
|
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
|
||||||
if (!subscriptionInDB) {
|
if (!subscriptionInDB) {
|
||||||
throw new BadRequestException(`You didn't subscribe to the ${plan} plan`);
|
throw new SubscriptionNotExists({ plan });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscriptionInDB.canceledAt) {
|
if (subscriptionInDB.canceledAt) {
|
||||||
throw new BadRequestException(
|
throw new SubscriptionHasBeenCanceled();
|
||||||
'Your subscription has already been canceled'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// should release the schedule first
|
// should release the schedule first
|
||||||
@@ -298,22 +307,20 @@ export class SubscriptionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new BadRequestException('Unknown user');
|
throw new UserNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
|
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
|
||||||
if (!subscriptionInDB) {
|
if (!subscriptionInDB) {
|
||||||
throw new BadRequestException(`You didn't subscribe to the ${plan} plan`);
|
throw new SubscriptionNotExists({ plan });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!subscriptionInDB.canceledAt) {
|
if (!subscriptionInDB.canceledAt) {
|
||||||
throw new BadRequestException('Your subscription has not been canceled');
|
throw new SubscriptionHasBeenCanceled();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscriptionInDB.end < new Date()) {
|
if (subscriptionInDB.end < new Date()) {
|
||||||
throw new BadRequestException(
|
throw new SubscriptionExpired();
|
||||||
'Your subscription is expired, please checkout again.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscriptionInDB.stripeScheduleId) {
|
if (subscriptionInDB.stripeScheduleId) {
|
||||||
@@ -354,23 +361,19 @@ export class SubscriptionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new BadRequestException('Unknown user');
|
throw new UserNotFound();
|
||||||
}
|
}
|
||||||
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
|
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
|
||||||
if (!subscriptionInDB) {
|
if (!subscriptionInDB) {
|
||||||
throw new BadRequestException(`You didn't subscribe to the ${plan} plan`);
|
throw new SubscriptionNotExists({ plan });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscriptionInDB.canceledAt) {
|
if (subscriptionInDB.canceledAt) {
|
||||||
throw new BadRequestException(
|
throw new SubscriptionHasBeenCanceled();
|
||||||
'Your subscription has already been canceled'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscriptionInDB.recurring === recurring) {
|
if (subscriptionInDB.recurring === recurring) {
|
||||||
throw new BadRequestException(
|
throw new SameSubscriptionRecurring({ recurring });
|
||||||
`You are already in ${recurring} recurring`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const price = await this.getPrice(
|
const price = await this.getPrice(
|
||||||
@@ -404,7 +407,7 @@ export class SubscriptionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new BadRequestException('Unknown user');
|
throw new UserNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -415,7 +418,7 @@ export class SubscriptionService {
|
|||||||
return portal.url;
|
return portal.url;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error('Failed to create customer portal.', e);
|
this.logger.error('Failed to create customer portal.', e);
|
||||||
throw new BadRequestException('Failed to create customer portal');
|
throw new CustomerPortalCreateFailed();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,9 +754,10 @@ export class SubscriptionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!prices.data.length) {
|
if (!prices.data.length) {
|
||||||
throw new BadRequestException(
|
throw new SubscriptionPlanNotFound({
|
||||||
`Unknown subscription plan ${plan} with ${recurring} recurring`
|
plan,
|
||||||
);
|
recurring,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return prices.data[0].id;
|
return prices.data[0].id;
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
import assert from 'node:assert';
|
import assert from 'node:assert';
|
||||||
|
|
||||||
import type { RawBodyRequest } from '@nestjs/common';
|
import type { RawBodyRequest } from '@nestjs/common';
|
||||||
import {
|
import { Controller, Logger, Post, Req } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
Logger,
|
|
||||||
NotAcceptableException,
|
|
||||||
Post,
|
|
||||||
Req,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
import { Public } from '../../core/auth';
|
import { Public } from '../../core/auth';
|
||||||
import { Config } from '../../fundamentals';
|
import { Config, InternalServerError } from '../../fundamentals';
|
||||||
|
|
||||||
@Controller('/api/stripe')
|
@Controller('/api/stripe')
|
||||||
export class StripeWebhook {
|
export class StripeWebhook {
|
||||||
@@ -55,9 +49,8 @@ export class StripeWebhook {
|
|||||||
this.logger.error('Failed to handle Stripe Webhook event.', e);
|
this.logger.error('Failed to handle Stripe Webhook event.', e);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
this.logger.error('Stripe Webhook error', err);
|
throw new InternalServerError(err.message);
|
||||||
throw new NotAcceptableException();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
|
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
|
||||||
# ------------------------------------------------------
|
# ------------------------------------------------------
|
||||||
|
|
||||||
|
type BlobNotFoundDataType {
|
||||||
|
blobId: String!
|
||||||
|
workspaceId: String!
|
||||||
|
}
|
||||||
|
|
||||||
type ChatMessage {
|
type ChatMessage {
|
||||||
attachments: [String!]
|
attachments: [String!]
|
||||||
content: String!
|
content: String!
|
||||||
@@ -65,6 +70,10 @@ type CopilotPromptMessageType {
|
|||||||
role: CopilotPromptMessageRole!
|
role: CopilotPromptMessageRole!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CopilotPromptNotFoundDataType {
|
||||||
|
name: String!
|
||||||
|
}
|
||||||
|
|
||||||
type CopilotPromptType {
|
type CopilotPromptType {
|
||||||
action: String
|
action: String
|
||||||
messages: [CopilotPromptMessageType!]!
|
messages: [CopilotPromptMessageType!]!
|
||||||
@@ -133,17 +142,98 @@ input DeleteSessionInput {
|
|||||||
workspaceId: String!
|
workspaceId: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DocAccessDeniedDataType {
|
||||||
|
docId: String!
|
||||||
|
workspaceId: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocHistoryNotFoundDataType {
|
||||||
|
docId: String!
|
||||||
|
timestamp: Int!
|
||||||
|
workspaceId: String!
|
||||||
|
}
|
||||||
|
|
||||||
type DocHistoryType {
|
type DocHistoryType {
|
||||||
id: String!
|
id: String!
|
||||||
timestamp: DateTime!
|
timestamp: DateTime!
|
||||||
workspaceId: String!
|
workspaceId: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DocNotFoundDataType {
|
||||||
|
docId: String!
|
||||||
|
workspaceId: String!
|
||||||
|
}
|
||||||
|
|
||||||
enum EarlyAccessType {
|
enum EarlyAccessType {
|
||||||
AI
|
AI
|
||||||
App
|
App
|
||||||
}
|
}
|
||||||
|
|
||||||
|
union ErrorDataUnion = BlobNotFoundDataType | CopilotPromptNotFoundDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MissingOauthQueryParameterDataType | NotInWorkspaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | VersionRejectedDataType | WorkspaceAccessDeniedDataType | WorkspaceNotFoundDataType | WorkspaceOwnerNotFoundDataType
|
||||||
|
|
||||||
|
enum ErrorNames {
|
||||||
|
ACCESS_DENIED
|
||||||
|
ACTION_FORBIDDEN
|
||||||
|
AUTHENTICATION_REQUIRED
|
||||||
|
BLOB_NOT_FOUND
|
||||||
|
BLOB_QUOTA_EXCEEDED
|
||||||
|
CANT_CHANGE_WORKSPACE_OWNER
|
||||||
|
COPILOT_ACTION_TAKEN
|
||||||
|
COPILOT_FAILED_TO_CREATE_MESSAGE
|
||||||
|
COPILOT_FAILED_TO_GENERATE_TEXT
|
||||||
|
COPILOT_MESSAGE_NOT_FOUND
|
||||||
|
COPILOT_PROMPT_NOT_FOUND
|
||||||
|
COPILOT_QUOTA_EXCEEDED
|
||||||
|
COPILOT_SESSION_DELETED
|
||||||
|
COPILOT_SESSION_NOT_FOUND
|
||||||
|
CUSTOMER_PORTAL_CREATE_FAILED
|
||||||
|
DOC_ACCESS_DENIED
|
||||||
|
DOC_HISTORY_NOT_FOUND
|
||||||
|
DOC_NOT_FOUND
|
||||||
|
EARLY_ACCESS_REQUIRED
|
||||||
|
EMAIL_ALREADY_USED
|
||||||
|
EMAIL_TOKEN_NOT_FOUND
|
||||||
|
EMAIL_VERIFICATION_REQUIRED
|
||||||
|
EXPECT_TO_PUBLISH_PAGE
|
||||||
|
EXPECT_TO_REVOKE_PUBLIC_PAGE
|
||||||
|
FAILED_TO_CHECKOUT
|
||||||
|
INTERNAL_SERVER_ERROR
|
||||||
|
INVALID_EMAIL
|
||||||
|
INVALID_EMAIL_TOKEN
|
||||||
|
INVALID_HISTORY_TIMESTAMP
|
||||||
|
INVALID_OAUTH_CALLBACK_STATE
|
||||||
|
INVALID_PASSWORD_LENGTH
|
||||||
|
INVALID_RUNTIME_CONFIG_TYPE
|
||||||
|
MAILER_SERVICE_IS_NOT_CONFIGURED
|
||||||
|
MEMBER_QUOTA_EXCEEDED
|
||||||
|
MISSING_OAUTH_QUERY_PARAMETER
|
||||||
|
NOT_IN_WORKSPACE
|
||||||
|
NO_COPILOT_PROVIDER_AVAILABLE
|
||||||
|
OAUTH_ACCOUNT_ALREADY_CONNECTED
|
||||||
|
OAUTH_STATE_EXPIRED
|
||||||
|
PAGE_IS_NOT_PUBLIC
|
||||||
|
RUNTIME_CONFIG_NOT_FOUND
|
||||||
|
SAME_EMAIL_PROVIDED
|
||||||
|
SAME_SUBSCRIPTION_RECURRING
|
||||||
|
SIGN_UP_FORBIDDEN
|
||||||
|
SUBSCRIPTION_ALREADY_EXISTS
|
||||||
|
SUBSCRIPTION_EXPIRED
|
||||||
|
SUBSCRIPTION_HAS_BEEN_CANCELED
|
||||||
|
SUBSCRIPTION_NOT_EXISTS
|
||||||
|
SUBSCRIPTION_PLAN_NOT_FOUND
|
||||||
|
TOO_MANY_REQUEST
|
||||||
|
UNKNOWN_OAUTH_PROVIDER
|
||||||
|
UNSPLASH_IS_NOT_CONFIGURED
|
||||||
|
USER_AVATAR_NOT_FOUND
|
||||||
|
USER_NOT_FOUND
|
||||||
|
VERSION_REJECTED
|
||||||
|
WORKSPACE_ACCESS_DENIED
|
||||||
|
WORKSPACE_NOT_FOUND
|
||||||
|
WORKSPACE_OWNER_NOT_FOUND
|
||||||
|
WRONG_SIGN_IN_CREDENTIALS
|
||||||
|
WRONG_SIGN_IN_METHOD
|
||||||
|
}
|
||||||
|
|
||||||
"""The type of workspace feature"""
|
"""The type of workspace feature"""
|
||||||
enum FeatureType {
|
enum FeatureType {
|
||||||
AIEarlyAccess
|
AIEarlyAccess
|
||||||
@@ -163,6 +253,21 @@ type HumanReadableQuotaType {
|
|||||||
storageQuota: String!
|
storageQuota: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InvalidHistoryTimestampDataType {
|
||||||
|
timestamp: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type InvalidPasswordLengthDataType {
|
||||||
|
max: Int!
|
||||||
|
min: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type InvalidRuntimeConfigTypeDataType {
|
||||||
|
get: String!
|
||||||
|
key: String!
|
||||||
|
want: String!
|
||||||
|
}
|
||||||
|
|
||||||
type InvitationType {
|
type InvitationType {
|
||||||
"""Invitee information"""
|
"""Invitee information"""
|
||||||
invitee: UserType!
|
invitee: UserType!
|
||||||
@@ -244,6 +349,10 @@ input ListUserInput {
|
|||||||
skip: Int = 0
|
skip: Int = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MissingOauthQueryParameterDataType {
|
||||||
|
name: String!
|
||||||
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean!
|
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean!
|
||||||
addAdminister(email: String!): Boolean!
|
addAdminister(email: String!): Boolean!
|
||||||
@@ -323,6 +432,10 @@ type Mutation {
|
|||||||
verifyEmail(token: String!): Boolean!
|
verifyEmail(token: String!): Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NotInWorkspaceDataType {
|
||||||
|
workspaceId: String!
|
||||||
|
}
|
||||||
|
|
||||||
enum OAuthProviderType {
|
enum OAuthProviderType {
|
||||||
GitHub
|
GitHub
|
||||||
Google
|
Google
|
||||||
@@ -355,6 +468,7 @@ type Query {
|
|||||||
"""Get current user"""
|
"""Get current user"""
|
||||||
currentUser: UserType
|
currentUser: UserType
|
||||||
earlyAccessUsers: [UserType!]!
|
earlyAccessUsers: [UserType!]!
|
||||||
|
error(name: ErrorNames!): ErrorDataUnion!
|
||||||
|
|
||||||
"""send workspace invitation"""
|
"""send workspace invitation"""
|
||||||
getInviteInfo(inviteId: String!): InvitationType!
|
getInviteInfo(inviteId: String!): InvitationType!
|
||||||
@@ -414,6 +528,10 @@ type RemoveAvatar {
|
|||||||
success: Boolean!
|
success: Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RuntimeConfigNotFoundDataType {
|
||||||
|
key: String!
|
||||||
|
}
|
||||||
|
|
||||||
enum RuntimeConfigType {
|
enum RuntimeConfigType {
|
||||||
Array
|
Array
|
||||||
Boolean
|
Boolean
|
||||||
@@ -427,6 +545,10 @@ The `SafeInt` scalar type represents non-fractional signed whole numeric values
|
|||||||
"""
|
"""
|
||||||
scalar SafeInt @specifiedBy(url: "https://www.ecma-international.org/ecma-262/#sec-number.issafeinteger")
|
scalar SafeInt @specifiedBy(url: "https://www.ecma-international.org/ecma-262/#sec-number.issafeinteger")
|
||||||
|
|
||||||
|
type SameSubscriptionRecurringDataType {
|
||||||
|
recurring: String!
|
||||||
|
}
|
||||||
|
|
||||||
type ServerConfigType {
|
type ServerConfigType {
|
||||||
"""server base url"""
|
"""server base url"""
|
||||||
baseUrl: String!
|
baseUrl: String!
|
||||||
@@ -483,6 +605,14 @@ type ServerRuntimeConfigType {
|
|||||||
value: JSON!
|
value: JSON!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SubscriptionAlreadyExistsDataType {
|
||||||
|
plan: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscriptionNotExistsDataType {
|
||||||
|
plan: String!
|
||||||
|
}
|
||||||
|
|
||||||
enum SubscriptionPlan {
|
enum SubscriptionPlan {
|
||||||
AI
|
AI
|
||||||
Enterprise
|
Enterprise
|
||||||
@@ -492,6 +622,11 @@ enum SubscriptionPlan {
|
|||||||
Team
|
Team
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SubscriptionPlanNotFoundDataType {
|
||||||
|
plan: String!
|
||||||
|
recurring: String!
|
||||||
|
}
|
||||||
|
|
||||||
type SubscriptionPrice {
|
type SubscriptionPrice {
|
||||||
amount: Int
|
amount: Int
|
||||||
currency: String!
|
currency: String!
|
||||||
@@ -516,6 +651,10 @@ enum SubscriptionStatus {
|
|||||||
Unpaid
|
Unpaid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UnknownOauthProviderDataType {
|
||||||
|
name: String!
|
||||||
|
}
|
||||||
|
|
||||||
input UpdateUserInput {
|
input UpdateUserInput {
|
||||||
"""User name"""
|
"""User name"""
|
||||||
name: String
|
name: String
|
||||||
@@ -617,10 +756,27 @@ type UserType {
|
|||||||
token: tokenType! @deprecated(reason: "use [/api/auth/authorize]")
|
token: tokenType! @deprecated(reason: "use [/api/auth/authorize]")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VersionRejectedDataType {
|
||||||
|
serverVersion: String!
|
||||||
|
version: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceAccessDeniedDataType {
|
||||||
|
workspaceId: String!
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspaceBlobSizes {
|
type WorkspaceBlobSizes {
|
||||||
size: SafeInt!
|
size: SafeInt!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceNotFoundDataType {
|
||||||
|
workspaceId: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceOwnerNotFoundDataType {
|
||||||
|
workspaceId: String!
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspacePage {
|
type WorkspacePage {
|
||||||
id: String!
|
id: String!
|
||||||
mode: PublicPageMode!
|
mode: PublicPageMode!
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ test('should not be able to sign in if email is invalid', async t => {
|
|||||||
.send({ email: '' })
|
.send({ email: '' })
|
||||||
.expect(400);
|
.expect(400);
|
||||||
|
|
||||||
t.is(res.body.message, 'Invalid email address');
|
t.is(res.body.message, 'An invalid email provided.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not be able to sign in if forbidden', async t => {
|
test('should not be able to sign in if forbidden', async t => {
|
||||||
@@ -130,7 +130,7 @@ test('should not be able to sign in if forbidden', async t => {
|
|||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post('/api/auth/sign-in')
|
.post('/api/auth/sign-in')
|
||||||
.send({ email: u1.email })
|
.send({ email: u1.email })
|
||||||
.expect(HttpStatus.BAD_REQUEST);
|
.expect(HttpStatus.FORBIDDEN);
|
||||||
|
|
||||||
t.true(mailer.sendSignInMail.notCalled);
|
t.true(mailer.sendSignInMail.notCalled);
|
||||||
|
|
||||||
|
|||||||
@@ -86,9 +86,11 @@ test('should not be able to visit private api if not signed in', async t => {
|
|||||||
.get('/private')
|
.get('/private')
|
||||||
.expect(HttpStatus.UNAUTHORIZED)
|
.expect(HttpStatus.UNAUTHORIZED)
|
||||||
.expect({
|
.expect({
|
||||||
statusCode: 401,
|
status: 401,
|
||||||
message: 'You are not signed in.',
|
code: 'Unauthorized',
|
||||||
error: 'Unauthorized',
|
type: 'AUTHENTICATION_REQUIRED',
|
||||||
|
name: 'AUTHENTICATION_REQUIRED',
|
||||||
|
message: 'You must sign in first to access this resource.',
|
||||||
});
|
});
|
||||||
|
|
||||||
t.assert(true);
|
t.assert(true);
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ test('should throw if email duplicated', async t => {
|
|||||||
const { auth } = t.context;
|
const { auth } = t.context;
|
||||||
|
|
||||||
await t.throwsAsync(() => auth.signUp('u1', 'u1@affine.pro', '1'), {
|
await t.throwsAsync(() => auth.signUp('u1', 'u1@affine.pro', '1'), {
|
||||||
message: 'Email was taken',
|
message: 'This email has already been registered.',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ test('should throw if user not found', async t => {
|
|||||||
const { auth } = t.context;
|
const { auth } = t.context;
|
||||||
|
|
||||||
await t.throwsAsync(() => auth.signIn('u2@affine.pro', '1'), {
|
await t.throwsAsync(() => auth.signIn('u2@affine.pro', '1'), {
|
||||||
message: 'Invalid sign in credentials',
|
message: 'Wrong user email or password.',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,7 +95,8 @@ test('should throw if password not set', async t => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await t.throwsAsync(() => auth.signIn('u2@affine.pro', '1'), {
|
await t.throwsAsync(() => auth.signIn('u2@affine.pro', '1'), {
|
||||||
message: 'User Password is not set. Should login through email link.',
|
message:
|
||||||
|
'You are trying to sign in by a different method than you signed up with.',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ test('should throw if password not match', async t => {
|
|||||||
const { auth } = t.context;
|
const { auth } = t.context;
|
||||||
|
|
||||||
await t.throwsAsync(() => auth.signIn('u1@affine.pro', '2'), {
|
await t.throwsAsync(() => auth.signIn('u1@affine.pro', '2'), {
|
||||||
message: 'Invalid sign in credentials',
|
message: 'Wrong user email or password.',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ test('should be able to change password', async t => {
|
|||||||
await t.throwsAsync(
|
await t.throwsAsync(
|
||||||
() => auth.signIn('u1@affine.pro', '1' /* old password */),
|
() => auth.signIn('u1@affine.pro', '1' /* old password */),
|
||||||
{
|
{
|
||||||
message: 'Invalid sign in credentials',
|
message: 'Wrong user email or password.',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -135,7 +136,7 @@ test('should be able to change email', async t => {
|
|||||||
await auth.changeEmail(u1.id, 'u2@affine.pro');
|
await auth.changeEmail(u1.id, 'u2@affine.pro');
|
||||||
|
|
||||||
await t.throwsAsync(() => auth.signIn('u1@affine.pro' /* old email */, '1'), {
|
await t.throwsAsync(() => auth.signIn('u1@affine.pro' /* old email */, '1'), {
|
||||||
message: 'Invalid sign in credentials',
|
message: 'Wrong user email or password.',
|
||||||
});
|
});
|
||||||
|
|
||||||
signedInU1 = await auth.signIn('u2@affine.pro', '1');
|
signedInU1 = await auth.signIn('u2@affine.pro', '1');
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import {
|
|
||||||
ForbiddenException,
|
|
||||||
HttpStatus,
|
|
||||||
INestApplication,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
|
||||||
import testFn, { TestFn } from 'ava';
|
|
||||||
import request from 'supertest';
|
|
||||||
|
|
||||||
import { Public } from '../src/core/auth';
|
|
||||||
import { createTestingApp } from './utils';
|
|
||||||
|
|
||||||
@Public()
|
|
||||||
@Resolver(() => String)
|
|
||||||
class TestResolver {
|
|
||||||
greating = 'hello world';
|
|
||||||
|
|
||||||
@Query(() => String)
|
|
||||||
hello() {
|
|
||||||
return this.greating;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Mutation(() => String)
|
|
||||||
update(@Args('greating') greating: string) {
|
|
||||||
this.greating = greating;
|
|
||||||
return this.greating;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Query(() => String)
|
|
||||||
errorQuery() {
|
|
||||||
throw new ForbiddenException('forbidden query');
|
|
||||||
}
|
|
||||||
|
|
||||||
@Query(() => String)
|
|
||||||
unknownErrorQuery() {
|
|
||||||
throw new Error('unknown error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const test = testFn as TestFn<{ app: INestApplication }>;
|
|
||||||
|
|
||||||
function gql(app: INestApplication, query: string) {
|
|
||||||
return request(app.getHttpServer())
|
|
||||||
.post('/graphql')
|
|
||||||
.send({ query })
|
|
||||||
.expect(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.beforeEach(async ctx => {
|
|
||||||
const { app } = await createTestingApp({
|
|
||||||
providers: [TestResolver],
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.context.app = app;
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach.always(async ctx => {
|
|
||||||
await ctx.context.app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should be able to execute query', async t => {
|
|
||||||
const res = await gql(t.context.app, `query { hello }`);
|
|
||||||
t.is(res.body.data.hello, 'hello world');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should be able to execute mutation', async t => {
|
|
||||||
const res = await gql(t.context.app, `mutation { update(greating: "hi") }`);
|
|
||||||
|
|
||||||
t.is(res.body.data.update, 'hi');
|
|
||||||
|
|
||||||
const newRes = await gql(t.context.app, `query { hello }`);
|
|
||||||
t.is(newRes.body.data.hello, 'hi');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should be able to handle known http exception', async t => {
|
|
||||||
const res = await gql(t.context.app, `query { errorQuery }`);
|
|
||||||
const err = res.body.errors[0];
|
|
||||||
t.is(err.message, 'forbidden query');
|
|
||||||
t.is(err.extensions.code, HttpStatus.FORBIDDEN);
|
|
||||||
t.is(err.extensions.status, HttpStatus[HttpStatus.FORBIDDEN]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should be able to handle unknown internal error', async t => {
|
|
||||||
const res = await gql(t.context.app, `query { unknownErrorQuery }`);
|
|
||||||
const err = res.body.errors[0];
|
|
||||||
t.is(err.message, 'Internal Server Error');
|
|
||||||
t.is(err.extensions.code, HttpStatus.INTERNAL_SERVER_ERROR);
|
|
||||||
t.is(err.extensions.status, HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR]);
|
|
||||||
});
|
|
||||||
199
packages/backend/server/tests/nestjs/error-handler.spec.ts
Normal file
199
packages/backend/server/tests/nestjs/error-handler.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import {
|
||||||
|
applyDecorators,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpStatus,
|
||||||
|
INestApplication,
|
||||||
|
Logger,
|
||||||
|
LoggerService,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||||
|
import {
|
||||||
|
SubscribeMessage as RawSubscribeMessage,
|
||||||
|
WebSocketGateway,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import testFn, { TestFn } from 'ava';
|
||||||
|
import Sinon from 'sinon';
|
||||||
|
import request from 'supertest';
|
||||||
|
|
||||||
|
import { Public } from '../../src/core/auth';
|
||||||
|
import {
|
||||||
|
AccessDenied,
|
||||||
|
GatewayErrorWrapper,
|
||||||
|
UserFriendlyError,
|
||||||
|
} from '../../src/fundamentals';
|
||||||
|
import { createTestingApp } from '../utils';
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Resolver(() => String)
|
||||||
|
class TestResolver {
|
||||||
|
greating = 'hello world';
|
||||||
|
|
||||||
|
@Query(() => String)
|
||||||
|
hello() {
|
||||||
|
return this.greating;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => String)
|
||||||
|
update(@Args('greating') greating: string) {
|
||||||
|
this.greating = greating;
|
||||||
|
return this.greating;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => String)
|
||||||
|
errorQuery() {
|
||||||
|
throw new AccessDenied();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => String)
|
||||||
|
unknownErrorQuery() {
|
||||||
|
throw new Error('unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Controller()
|
||||||
|
class TestController {
|
||||||
|
@Get('/ok')
|
||||||
|
ok() {
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/throw-known-error')
|
||||||
|
throwKnownError() {
|
||||||
|
throw new AccessDenied();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/throw-unknown-error')
|
||||||
|
throwUnknownError() {
|
||||||
|
throw new Error('Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubscribeMessage = (event: string) =>
|
||||||
|
applyDecorators(GatewayErrorWrapper(event), RawSubscribeMessage(event));
|
||||||
|
|
||||||
|
@WebSocketGateway({ transports: ['websocket'], path: '/ws' })
|
||||||
|
class TestGateway {
|
||||||
|
@SubscribeMessage('event:ok')
|
||||||
|
async ok() {
|
||||||
|
return {
|
||||||
|
data: 'ok',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('event:throw-known-error')
|
||||||
|
async throwKnownError() {
|
||||||
|
throw new AccessDenied();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('event:throw-unknown-error')
|
||||||
|
async throwUnknownError() {
|
||||||
|
throw new Error('Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const test = testFn as TestFn<{
|
||||||
|
app: INestApplication;
|
||||||
|
logger: Sinon.SinonStubbedInstance<LoggerService>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function gql(app: INestApplication, query: string) {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post('/graphql')
|
||||||
|
.send({ query })
|
||||||
|
.expect(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
const { app } = await createTestingApp({
|
||||||
|
providers: [TestResolver, TestGateway],
|
||||||
|
controllers: [TestController],
|
||||||
|
});
|
||||||
|
|
||||||
|
context.logger = Sinon.stub(new Logger().localInstance);
|
||||||
|
|
||||||
|
context.app = app;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach.always(async ctx => {
|
||||||
|
await ctx.context.app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to execute query', async t => {
|
||||||
|
const res = await gql(t.context.app, `query { hello }`);
|
||||||
|
t.is(res.body.data.hello, 'hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to handle known user error in graphql query', async t => {
|
||||||
|
const res = await gql(t.context.app, `query { errorQuery }`);
|
||||||
|
const err = res.body.errors[0];
|
||||||
|
t.is(err.message, 'You do not have permission to access this resource.');
|
||||||
|
t.is(err.extensions.status, HttpStatus.FORBIDDEN);
|
||||||
|
t.is(err.extensions.name, 'ACCESS_DENIED');
|
||||||
|
t.true(t.context.logger.error.notCalled);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to handle unknown internal error in graphql query', async t => {
|
||||||
|
const res = await gql(t.context.app, `query { unknownErrorQuery }`);
|
||||||
|
const err = res.body.errors[0];
|
||||||
|
t.is(err.message, 'An internal error occurred.');
|
||||||
|
t.is(err.extensions.status, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
t.is(err.extensions.name, 'INTERNAL_SERVER_ERROR');
|
||||||
|
t.true(t.context.logger.error.calledOnceWith('Internal server error'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to respond request', async t => {
|
||||||
|
const res = await request(t.context.app.getHttpServer())
|
||||||
|
.get('/ok')
|
||||||
|
.expect(200);
|
||||||
|
t.is(res.text, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to handle known user error in http request', async t => {
|
||||||
|
const res = await request(t.context.app.getHttpServer())
|
||||||
|
.get('/throw-known-error')
|
||||||
|
.expect(HttpStatus.FORBIDDEN);
|
||||||
|
t.is(res.body.message, 'You do not have permission to access this resource.');
|
||||||
|
t.is(res.body.name, 'ACCESS_DENIED');
|
||||||
|
t.true(t.context.logger.error.notCalled);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to handle unknown internal error in http request', async t => {
|
||||||
|
const res = await request(t.context.app.getHttpServer())
|
||||||
|
.get('/throw-unknown-error')
|
||||||
|
.expect(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
t.is(res.body.message, 'An internal error occurred.');
|
||||||
|
t.is(res.body.name, 'INTERNAL_SERVER_ERROR');
|
||||||
|
t.true(t.context.logger.error.calledOnceWith('Internal server error'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hard to test through websocket, will call event handler directly
|
||||||
|
test('should be able to response websocket event', async t => {
|
||||||
|
const gateway = t.context.app.get(TestGateway);
|
||||||
|
|
||||||
|
const res = await gateway.ok();
|
||||||
|
t.is(res.data, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to handle known user error in websocket event', async t => {
|
||||||
|
const gateway = t.context.app.get(TestGateway);
|
||||||
|
|
||||||
|
const { error } = (await gateway.throwKnownError()) as unknown as {
|
||||||
|
error: UserFriendlyError;
|
||||||
|
};
|
||||||
|
t.is(error.message, 'You do not have permission to access this resource.');
|
||||||
|
t.is(error.name, 'ACCESS_DENIED');
|
||||||
|
t.true(t.context.logger.error.notCalled);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to handle unknown internal error in websocket event', async t => {
|
||||||
|
const gateway = t.context.app.get(TestGateway);
|
||||||
|
|
||||||
|
const { error } = (await gateway.throwUnknownError()) as unknown as {
|
||||||
|
error: UserFriendlyError;
|
||||||
|
};
|
||||||
|
t.is(error.message, 'An internal error occurred.');
|
||||||
|
t.is(error.name, 'INTERNAL_SERVER_ERROR');
|
||||||
|
t.true(t.context.logger.error.calledOnceWith('Internal server error'));
|
||||||
|
});
|
||||||
@@ -86,12 +86,15 @@ test('should throw if provider is invalid', async t => {
|
|||||||
.get('/oauth/login?provider=Invalid')
|
.get('/oauth/login?provider=Invalid')
|
||||||
.expect(HttpStatus.BAD_REQUEST)
|
.expect(HttpStatus.BAD_REQUEST)
|
||||||
.expect({
|
.expect({
|
||||||
statusCode: 400,
|
status: 400,
|
||||||
message: 'Invalid OAuth provider',
|
code: 'Bad Request',
|
||||||
error: 'Bad Request',
|
type: 'INVALID_INPUT',
|
||||||
|
name: 'UNKNOWN_OAUTH_PROVIDER',
|
||||||
|
message: 'Unknown authentication provider Invalid.',
|
||||||
|
data: { name: 'Invalid' },
|
||||||
});
|
});
|
||||||
|
|
||||||
t.assert(true);
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should be able to save oauth state', async t => {
|
test('should be able to save oauth state', async t => {
|
||||||
@@ -124,12 +127,15 @@ test('should throw if code is missing in callback uri', async t => {
|
|||||||
.get('/oauth/callback')
|
.get('/oauth/callback')
|
||||||
.expect(HttpStatus.BAD_REQUEST)
|
.expect(HttpStatus.BAD_REQUEST)
|
||||||
.expect({
|
.expect({
|
||||||
statusCode: 400,
|
status: 400,
|
||||||
message: 'Missing query parameter `code`',
|
code: 'Bad Request',
|
||||||
error: 'Bad Request',
|
type: 'BAD_REQUEST',
|
||||||
|
name: 'MISSING_OAUTH_QUERY_PARAMETER',
|
||||||
|
message: 'Missing query parameter `code`.',
|
||||||
|
data: { name: 'code' },
|
||||||
});
|
});
|
||||||
|
|
||||||
t.assert(true);
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw if state is missing in callback uri', async t => {
|
test('should throw if state is missing in callback uri', async t => {
|
||||||
@@ -139,27 +145,50 @@ test('should throw if state is missing in callback uri', async t => {
|
|||||||
.get('/oauth/callback?code=1')
|
.get('/oauth/callback?code=1')
|
||||||
.expect(HttpStatus.BAD_REQUEST)
|
.expect(HttpStatus.BAD_REQUEST)
|
||||||
.expect({
|
.expect({
|
||||||
statusCode: 400,
|
status: 400,
|
||||||
message: 'Invalid callback state parameter',
|
code: 'Bad Request',
|
||||||
error: 'Bad Request',
|
type: 'BAD_REQUEST',
|
||||||
|
name: 'MISSING_OAUTH_QUERY_PARAMETER',
|
||||||
|
message: 'Missing query parameter `state`.',
|
||||||
|
data: { name: 'state' },
|
||||||
});
|
});
|
||||||
|
|
||||||
t.assert(true);
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw if state is expired', async t => {
|
test('should throw if state is expired', async t => {
|
||||||
|
const { app, oauth } = t.context;
|
||||||
|
Sinon.stub(oauth, 'isValidState').resolves(true);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/oauth/callback?code=1&state=1')
|
||||||
|
.expect(HttpStatus.BAD_REQUEST)
|
||||||
|
.expect({
|
||||||
|
status: 400,
|
||||||
|
code: 'Bad Request',
|
||||||
|
type: 'BAD_REQUEST',
|
||||||
|
name: 'OAUTH_STATE_EXPIRED',
|
||||||
|
message: 'OAuth state expired, please try again.',
|
||||||
|
});
|
||||||
|
|
||||||
|
t.pass();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw if state is invalid', async t => {
|
||||||
const { app } = t.context;
|
const { app } = t.context;
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get('/oauth/callback?code=1&state=1')
|
.get('/oauth/callback?code=1&state=1')
|
||||||
.expect(HttpStatus.BAD_REQUEST)
|
.expect(HttpStatus.BAD_REQUEST)
|
||||||
.expect({
|
.expect({
|
||||||
statusCode: 400,
|
status: 400,
|
||||||
message: 'OAuth state expired, please try again.',
|
code: 'Bad Request',
|
||||||
error: 'Bad Request',
|
type: 'BAD_REQUEST',
|
||||||
|
name: 'INVALID_OAUTH_CALLBACK_STATE',
|
||||||
|
message: 'Invalid callback state parameter.',
|
||||||
});
|
});
|
||||||
|
|
||||||
t.assert(true);
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw if provider is missing in state', async t => {
|
test('should throw if provider is missing in state', async t => {
|
||||||
@@ -167,17 +196,21 @@ test('should throw if provider is missing in state', async t => {
|
|||||||
|
|
||||||
// @ts-expect-error mock
|
// @ts-expect-error mock
|
||||||
Sinon.stub(oauth, 'getOAuthState').resolves({});
|
Sinon.stub(oauth, 'getOAuthState').resolves({});
|
||||||
|
Sinon.stub(oauth, 'isValidState').resolves(true);
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(`/oauth/callback?code=1&state=1`)
|
.get(`/oauth/callback?code=1&state=1`)
|
||||||
.expect(HttpStatus.BAD_REQUEST)
|
.expect(HttpStatus.BAD_REQUEST)
|
||||||
.expect({
|
.expect({
|
||||||
statusCode: 400,
|
status: 400,
|
||||||
message: 'Missing callback state parameter `provider`',
|
code: 'Bad Request',
|
||||||
error: 'Bad Request',
|
type: 'BAD_REQUEST',
|
||||||
|
name: 'MISSING_OAUTH_QUERY_PARAMETER',
|
||||||
|
message: 'Missing query parameter `provider`.',
|
||||||
|
data: { name: 'provider' },
|
||||||
});
|
});
|
||||||
|
|
||||||
t.assert(true);
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw if provider is invalid in callback uri', async t => {
|
test('should throw if provider is invalid in callback uri', async t => {
|
||||||
@@ -185,23 +218,28 @@ test('should throw if provider is invalid in callback uri', async t => {
|
|||||||
|
|
||||||
// @ts-expect-error mock
|
// @ts-expect-error mock
|
||||||
Sinon.stub(oauth, 'getOAuthState').resolves({ provider: 'Invalid' });
|
Sinon.stub(oauth, 'getOAuthState').resolves({ provider: 'Invalid' });
|
||||||
|
Sinon.stub(oauth, 'isValidState').resolves(true);
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(`/oauth/callback?code=1&state=1`)
|
.get(`/oauth/callback?code=1&state=1`)
|
||||||
.expect(HttpStatus.BAD_REQUEST)
|
.expect(HttpStatus.BAD_REQUEST)
|
||||||
.expect({
|
.expect({
|
||||||
statusCode: 400,
|
status: 400,
|
||||||
message: 'Invalid provider',
|
code: 'Bad Request',
|
||||||
error: 'Bad Request',
|
type: 'INVALID_INPUT',
|
||||||
|
name: 'UNKNOWN_OAUTH_PROVIDER',
|
||||||
|
message: 'Unknown authentication provider Invalid.',
|
||||||
|
data: { name: 'Invalid' },
|
||||||
});
|
});
|
||||||
|
|
||||||
t.assert(true);
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
function mockOAuthProvider(app: INestApplication, email: string) {
|
function mockOAuthProvider(app: INestApplication, email: string) {
|
||||||
const provider = app.get(GoogleOAuthProvider);
|
const provider = app.get(GoogleOAuthProvider);
|
||||||
const oauth = app.get(OAuthService);
|
const oauth = app.get(OAuthService);
|
||||||
|
|
||||||
|
Sinon.stub(oauth, 'isValidState').resolves(true);
|
||||||
Sinon.stub(oauth, 'getOAuthState').resolves({
|
Sinon.stub(oauth, 'getOAuthState').resolves({
|
||||||
provider: OAuthProviderName.Google,
|
provider: OAuthProviderName.Google,
|
||||||
redirectUri: '/',
|
redirectUri: '/',
|
||||||
@@ -259,7 +297,7 @@ test('should throw if account register in another way', async t => {
|
|||||||
t.is(link.pathname, '/signIn');
|
t.is(link.pathname, '/signIn');
|
||||||
t.is(
|
t.is(
|
||||||
link.searchParams.get('error'),
|
link.searchParams.get('error'),
|
||||||
'The account with provided email is not register in the same way.'
|
'You are trying to sign in by a different method than you signed up with.'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ test('should throw if user has subscription already', async t => {
|
|||||||
redirectUrl: '',
|
redirectUrl: '',
|
||||||
idempotencyKey: '',
|
idempotencyKey: '',
|
||||||
}),
|
}),
|
||||||
{ message: "You've already subscribed to the pro plan" }
|
{ message: 'You have already subscribed to the pro plan.' }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { Response } from 'supertest';
|
|||||||
import { AppModule, FunctionalityModules } from '../../src/app.module';
|
import { AppModule, FunctionalityModules } from '../../src/app.module';
|
||||||
import { AuthGuard, AuthModule } from '../../src/core/auth';
|
import { AuthGuard, AuthModule } from '../../src/core/auth';
|
||||||
import { UserFeaturesInit1698652531198 } from '../../src/data/migrations/1698652531198-user-features-init';
|
import { UserFeaturesInit1698652531198 } from '../../src/data/migrations/1698652531198-user-features-init';
|
||||||
|
import { GlobalExceptionFilter } from '../../src/fundamentals';
|
||||||
import { GqlModule } from '../../src/fundamentals/graphql';
|
import { GqlModule } from '../../src/fundamentals/graphql';
|
||||||
|
|
||||||
async function flushDB(client: PrismaClient) {
|
async function flushDB(client: PrismaClient) {
|
||||||
@@ -116,6 +117,7 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) {
|
|||||||
logger: ['warn'],
|
logger: ['warn'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter()));
|
||||||
app.use(
|
app.use(
|
||||||
graphqlUploadExpress({
|
graphqlUploadExpress({
|
||||||
maxFileSize: 10 * 1024 * 1024,
|
maxFileSize: 10 * 1024 * 1024,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
acceptInviteById,
|
acceptInviteById,
|
||||||
createTestingApp,
|
createTestingApp,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
currentUser,
|
|
||||||
getWorkspacePublicPages,
|
getWorkspacePublicPages,
|
||||||
inviteUser,
|
inviteUser,
|
||||||
publishPage,
|
publishPage,
|
||||||
@@ -43,19 +42,6 @@ test('should register a user', async t => {
|
|||||||
t.is(user.email, 'u1@affine.pro', 'user.email is not valid');
|
t.is(user.email, 'u1@affine.pro', 'user.email is not valid');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip('should be throttled at call signUp', async t => {
|
|
||||||
const { app } = t.context;
|
|
||||||
let token = '';
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
token = (await signUp(app, `u${i}`, `u${i}@affine.pro`, `${i}`)).token
|
|
||||||
.token;
|
|
||||||
// throttles are applied to each endpoint separately
|
|
||||||
await currentUser(app, token);
|
|
||||||
}
|
|
||||||
await t.throwsAsync(() => signUp(app, 'u11', 'u11@affine.pro', '11'));
|
|
||||||
await t.throwsAsync(() => currentUser(app, token));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create a workspace', async t => {
|
test('should create a workspace', async t => {
|
||||||
const { app } = t.context;
|
const { app } = t.context;
|
||||||
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||||
@@ -128,14 +114,22 @@ test('should share a page', async t => {
|
|||||||
t.is(resp4.statusCode, 404, 'should not get shared doc without token');
|
t.is(resp4.statusCode, 404, 'should not get shared doc without token');
|
||||||
|
|
||||||
const msg1 = await publishPage(app, u2.token.token, 'not_exists_ws', 'page2');
|
const msg1 = await publishPage(app, u2.token.token, 'not_exists_ws', 'page2');
|
||||||
t.is(msg1, 'Permission denied', 'unauthorized user can share page');
|
t.is(
|
||||||
|
msg1,
|
||||||
|
'You do not have permission to access workspace not_exists_ws.',
|
||||||
|
'unauthorized user can share page'
|
||||||
|
);
|
||||||
const msg2 = await revokePublicPage(
|
const msg2 = await revokePublicPage(
|
||||||
app,
|
app,
|
||||||
u2.token.token,
|
u2.token.token,
|
||||||
'not_exists_ws',
|
'not_exists_ws',
|
||||||
'page2'
|
'page2'
|
||||||
);
|
);
|
||||||
t.is(msg2, 'Permission denied', 'unauthorized user can share page');
|
t.is(
|
||||||
|
msg2,
|
||||||
|
'You do not have permission to access workspace not_exists_ws.',
|
||||||
|
'unauthorized user can share page'
|
||||||
|
);
|
||||||
|
|
||||||
await acceptInviteById(
|
await acceptInviteById(
|
||||||
app,
|
app,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type GraphQLQuery,
|
type GraphQLQuery,
|
||||||
type QueryOptions,
|
type QueryOptions,
|
||||||
type RequestOptions,
|
type RequestOptions,
|
||||||
|
UserFriendlyError,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import {
|
import {
|
||||||
GeneralNetworkError,
|
GeneralNetworkError,
|
||||||
@@ -33,27 +34,16 @@ function codeToError(code: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ErrorType =
|
export function resolveError(err: any) {
|
||||||
| GraphQLError[]
|
const standardError =
|
||||||
| GraphQLError
|
err instanceof GraphQLError
|
||||||
| { status: number }
|
? new UserFriendlyError(err.extensions)
|
||||||
| Error
|
: UserFriendlyError.fromAnyError(err);
|
||||||
| string;
|
|
||||||
|
|
||||||
export function resolveError(src: ErrorType) {
|
return codeToError(standardError.status);
|
||||||
if (typeof src === 'string') {
|
|
||||||
return new GeneralNetworkError(src);
|
|
||||||
} else if (src instanceof GraphQLError || Array.isArray(src)) {
|
|
||||||
// only resolve the first error
|
|
||||||
const error = Array.isArray(src) ? src.at(0) : src;
|
|
||||||
const code = error?.extensions?.code;
|
|
||||||
return codeToError(code ?? 500);
|
|
||||||
} else {
|
|
||||||
return codeToError(src instanceof Error ? 500 : src.status);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleError(src: ErrorType) {
|
export function handleError(src: any) {
|
||||||
const err = resolveError(src);
|
const err = resolveError(src);
|
||||||
if (err instanceof UnauthorizedError) {
|
if (err instanceof UnauthorizedError) {
|
||||||
getCurrentStore().set(showAILoginRequiredAtom, true);
|
getCurrentStore().set(showAILoginRequiredAtom, true);
|
||||||
@@ -66,8 +56,7 @@ const fetcher = async <Query extends GraphQLQuery>(
|
|||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
return await defaultFetcher<Query>(options);
|
return await defaultFetcher<Query>(options);
|
||||||
} catch (_err) {
|
} catch (err) {
|
||||||
const err = _err as GraphQLError | GraphQLError[] | Error | string;
|
|
||||||
throw handleError(err);
|
throw handleError(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DebugLogger } from '@affine/debug';
|
import { DebugLogger } from '@affine/debug';
|
||||||
|
import { UserFriendlyError } from '@affine/graphql';
|
||||||
import { fromPromise, Service } from '@toeverything/infra';
|
import { fromPromise, Service } from '@toeverything/infra';
|
||||||
|
|
||||||
import { BackendError, NetworkError } from '../error';
|
import { BackendError, NetworkError } from '../error';
|
||||||
@@ -75,9 +76,7 @@ export class FetchService extends Service {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new BackendError(
|
throw new BackendError(UserFriendlyError.fromAnyError(reason));
|
||||||
new Error(`${res.status} ${res.statusText}`, reason)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
gqlFetcherFactory,
|
gqlFetcherFactory,
|
||||||
GraphQLError,
|
|
||||||
type GraphQLQuery,
|
type GraphQLQuery,
|
||||||
type QueryOptions,
|
type QueryOptions,
|
||||||
type QueryResponse,
|
type QueryResponse,
|
||||||
|
UserFriendlyError,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import { fromPromise, Service } from '@toeverything/infra';
|
import { fromPromise, Service } from '@toeverything/infra';
|
||||||
import type { Observable } from 'rxjs';
|
import type { Observable } from 'rxjs';
|
||||||
@@ -39,15 +39,13 @@ export class GraphQLService extends Service {
|
|||||||
try {
|
try {
|
||||||
return await this.rawGql(options);
|
return await this.rawGql(options);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Array) {
|
const standardError = UserFriendlyError.fromAnyError(err);
|
||||||
for (const error of err) {
|
|
||||||
if (error instanceof GraphQLError && error.extensions?.code === 403) {
|
if (standardError.status === 403) {
|
||||||
this.framework.get(AuthService).session.revalidate();
|
this.framework.get(AuthService).session.revalidate();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
throw new BackendError(new Error('Graphql Error'));
|
throw new BackendError(standardError);
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
deleteBlobMutation,
|
deleteBlobMutation,
|
||||||
fetcher,
|
fetcher,
|
||||||
findGraphQLError,
|
|
||||||
getBaseUrl,
|
getBaseUrl,
|
||||||
listBlobsQuery,
|
listBlobsQuery,
|
||||||
setBlobMutation,
|
setBlobMutation,
|
||||||
|
UserFriendlyError,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import type { BlobStorage } from '@toeverything/infra';
|
import type { BlobStorage } from '@toeverything/infra';
|
||||||
import { BlobStorageOverCapacity } from '@toeverything/infra';
|
import { BlobStorageOverCapacity } from '@toeverything/infra';
|
||||||
@@ -44,13 +44,9 @@ export class CloudBlobStorage implements BlobStorage {
|
|||||||
})
|
})
|
||||||
.then(res => res.setBlob)
|
.then(res => res.setBlob)
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
const uploadError = findGraphQLError(
|
const error = UserFriendlyError.fromAnyError(err);
|
||||||
err,
|
if (error.status === 413) {
|
||||||
e => e.extensions.code === 413
|
throw new BlobStorageOverCapacity(error);
|
||||||
);
|
|
||||||
|
|
||||||
if (uploadError) {
|
|
||||||
throw new BlobStorageOverCapacity(uploadError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { DebugLogger } from '@affine/debug';
|
import { DebugLogger } from '@affine/debug';
|
||||||
|
import {
|
||||||
|
ErrorNames,
|
||||||
|
UserFriendlyError,
|
||||||
|
type UserFriendlyErrorResponse,
|
||||||
|
} from '@affine/graphql';
|
||||||
import type { DocServer } from '@toeverything/infra';
|
import type { DocServer } from '@toeverything/infra';
|
||||||
import { throwIfAborted } from '@toeverything/infra';
|
import { throwIfAborted } from '@toeverything/infra';
|
||||||
import type { Socket } from 'socket.io-client';
|
import type { Socket } from 'socket.io-client';
|
||||||
@@ -9,6 +14,8 @@ import { base64ToUint8Array, uint8ArrayToBase64 } from '../../utils/base64';
|
|||||||
|
|
||||||
const logger = new DebugLogger('affine-cloud-doc-engine-server');
|
const logger = new DebugLogger('affine-cloud-doc-engine-server');
|
||||||
|
|
||||||
|
type WebsocketResponse<T> = { error: UserFriendlyErrorResponse } | { data: T };
|
||||||
|
|
||||||
export class CloudDocEngineServer implements DocServer {
|
export class CloudDocEngineServer implements DocServer {
|
||||||
interruptCb: ((reason: string) => void) | null = null;
|
interruptCb: ((reason: string) => void) | null = null;
|
||||||
SEND_TIMEOUT = 30000;
|
SEND_TIMEOUT = 30000;
|
||||||
@@ -31,21 +38,24 @@ export class CloudDocEngineServer implements DocServer {
|
|||||||
|
|
||||||
const stateVector = state ? await uint8ArrayToBase64(state) : undefined;
|
const stateVector = state ? await uint8ArrayToBase64(state) : undefined;
|
||||||
|
|
||||||
const response:
|
const response: WebsocketResponse<{
|
||||||
| { error: any }
|
missing: string;
|
||||||
| { data: { missing: string; state: string; timestamp: number } } =
|
state: string;
|
||||||
await this.socket.timeout(this.SEND_TIMEOUT).emitWithAck('doc-load-v2', {
|
timestamp: number;
|
||||||
|
}> = await this.socket
|
||||||
|
.timeout(this.SEND_TIMEOUT)
|
||||||
|
.emitWithAck('doc-load-v2', {
|
||||||
workspaceId: this.workspaceId,
|
workspaceId: this.workspaceId,
|
||||||
guid: docId,
|
guid: docId,
|
||||||
stateVector,
|
stateVector,
|
||||||
});
|
});
|
||||||
|
|
||||||
if ('error' in response) {
|
if ('error' in response) {
|
||||||
// TODO: result `EventError` with server
|
const error = new UserFriendlyError(response.error);
|
||||||
if (response.error.code === 'DOC_NOT_FOUND') {
|
if (error.name === ErrorNames.DOC_NOT_FOUND) {
|
||||||
return null;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.error.message);
|
throw error;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
@@ -60,11 +70,7 @@ export class CloudDocEngineServer implements DocServer {
|
|||||||
async pushDoc(docId: string, data: Uint8Array) {
|
async pushDoc(docId: string, data: Uint8Array) {
|
||||||
const payload = await uint8ArrayToBase64(data);
|
const payload = await uint8ArrayToBase64(data);
|
||||||
|
|
||||||
const response: {
|
const response: WebsocketResponse<{ timestamp: number }> = await this.socket
|
||||||
// TODO: reuse `EventError` with server
|
|
||||||
error?: any;
|
|
||||||
data: { timestamp: number };
|
|
||||||
} = await this.socket
|
|
||||||
.timeout(this.SEND_TIMEOUT)
|
.timeout(this.SEND_TIMEOUT)
|
||||||
.emitWithAck('client-update-v2', {
|
.emitWithAck('client-update-v2', {
|
||||||
workspaceId: this.workspaceId,
|
workspaceId: this.workspaceId,
|
||||||
@@ -72,38 +78,34 @@ export class CloudDocEngineServer implements DocServer {
|
|||||||
updates: [payload],
|
updates: [payload],
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: raise error with different code to users
|
if ('error' in response) {
|
||||||
if (response.error) {
|
|
||||||
logger.error('client-update-v2 error', {
|
logger.error('client-update-v2 error', {
|
||||||
workspaceId: this.workspaceId,
|
workspaceId: this.workspaceId,
|
||||||
guid: docId,
|
guid: docId,
|
||||||
response,
|
response,
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new Error(response.error);
|
throw new UserFriendlyError(response.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { serverClock: response.data.timestamp };
|
return { serverClock: response.data.timestamp };
|
||||||
}
|
}
|
||||||
async loadServerClock(after: number): Promise<Map<string, number>> {
|
async loadServerClock(after: number): Promise<Map<string, number>> {
|
||||||
const response: {
|
const response: WebsocketResponse<Record<string, number>> =
|
||||||
// TODO: reuse `EventError` with server
|
await this.socket
|
||||||
error?: any;
|
|
||||||
data: Record<string, number>;
|
|
||||||
} = await this.socket
|
|
||||||
.timeout(this.SEND_TIMEOUT)
|
.timeout(this.SEND_TIMEOUT)
|
||||||
.emitWithAck('client-pre-sync', {
|
.emitWithAck('client-pre-sync', {
|
||||||
workspaceId: this.workspaceId,
|
workspaceId: this.workspaceId,
|
||||||
timestamp: after,
|
timestamp: after,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.error) {
|
if ('error' in response) {
|
||||||
logger.error('client-pre-sync error', {
|
logger.error('client-pre-sync error', {
|
||||||
workspaceId: this.workspaceId,
|
workspaceId: this.workspaceId,
|
||||||
response,
|
response,
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new Error(response.error);
|
throw new UserFriendlyError(response.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Map(Object.entries(response.data));
|
return new Map(Object.entries(response.data));
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ config:
|
|||||||
declarationKind: interface
|
declarationKind: interface
|
||||||
avoidOptionals: true
|
avoidOptionals: true
|
||||||
preResolveTypes: true
|
preResolveTypes: true
|
||||||
onlyOperationTypes: true
|
|
||||||
namingConvention:
|
namingConvention:
|
||||||
enumValues: keep
|
enumValues: keep
|
||||||
scalars:
|
scalars:
|
||||||
|
|||||||
@@ -102,11 +102,8 @@ describe('GraphQL fetcher', () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(gql({ query, variables: void 0 })).rejects
|
await expect(
|
||||||
.toMatchInlineSnapshot(`
|
gql({ query, variables: void 0 })
|
||||||
[
|
).rejects.toMatchInlineSnapshot(`[GraphQLError: error]`);
|
||||||
[GraphQLError: error],
|
|
||||||
]
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,26 +1,59 @@
|
|||||||
import { GraphQLError as BaseGraphQLError } from 'graphql';
|
import { GraphQLError as BaseGraphQLError } from 'graphql';
|
||||||
import { identity } from 'lodash-es';
|
|
||||||
|
|
||||||
interface KnownGraphQLErrorExtensions {
|
import { type ErrorDataUnion, ErrorNames } from './schema';
|
||||||
code: number;
|
|
||||||
status: string;
|
export interface UserFriendlyErrorResponse {
|
||||||
originalError?: unknown;
|
status: number;
|
||||||
|
code: string;
|
||||||
|
type: string;
|
||||||
|
name: ErrorNames;
|
||||||
|
message: string;
|
||||||
|
args?: any;
|
||||||
stacktrace?: string;
|
stacktrace?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UserFriendlyError implements UserFriendlyErrorResponse {
|
||||||
|
status = this.response.status;
|
||||||
|
code = this.response.code;
|
||||||
|
type = this.response.type;
|
||||||
|
name = this.response.name;
|
||||||
|
message = this.response.message;
|
||||||
|
args = this.response.args;
|
||||||
|
stacktrace = this.response.stacktrace;
|
||||||
|
|
||||||
|
static fromAnyError(response: any) {
|
||||||
|
if (response instanceof GraphQLError) {
|
||||||
|
return new UserFriendlyError(response.extensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof response === 'object' && response.type && response.name) {
|
||||||
|
return new UserFriendlyError(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UserFriendlyError({
|
||||||
|
status: 500,
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
type: 'INTERNAL_SERVER_ERROR',
|
||||||
|
name: ErrorNames.INTERNAL_SERVER_ERROR,
|
||||||
|
message: 'Internal server error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private readonly response: UserFriendlyErrorResponse) {}
|
||||||
|
}
|
||||||
|
|
||||||
export class GraphQLError extends BaseGraphQLError {
|
export class GraphQLError extends BaseGraphQLError {
|
||||||
// @ts-expect-error better to be a known type without any type casting
|
// @ts-expect-error better to be a known type without any type casting
|
||||||
override extensions!: KnownGraphQLErrorExtensions;
|
override extensions!: UserFriendlyErrorResponse;
|
||||||
}
|
|
||||||
export function findGraphQLError(
|
|
||||||
errOrArr: any,
|
|
||||||
filter: (err: GraphQLError) => boolean = identity
|
|
||||||
): GraphQLError | undefined {
|
|
||||||
if (errOrArr instanceof GraphQLError) {
|
|
||||||
return filter(errOrArr) ? errOrArr : undefined;
|
|
||||||
} else if (Array.isArray(errOrArr)) {
|
|
||||||
return errOrArr.find(err => err instanceof GraphQLError && filter(err));
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToPascalCase<S extends string> = S extends `${infer A}_${infer B}`
|
||||||
|
? `${Capitalize<Lowercase<A>>}${ToPascalCase<B>}`
|
||||||
|
: Capitalize<Lowercase<S>>;
|
||||||
|
|
||||||
|
export type ErrorData = {
|
||||||
|
[K in ErrorNames]: Extract<
|
||||||
|
ErrorDataUnion,
|
||||||
|
{ __typename?: `${ToPascalCase<K>}DataType` }
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -195,9 +195,9 @@ export const gqlFetcherFactory = (
|
|||||||
const result = (await res.json()) as ExecutionResult;
|
const result = (await res.json()) as ExecutionResult;
|
||||||
if (res.status >= 400 || result.errors) {
|
if (res.status >= 400 || result.errors) {
|
||||||
if (result.errors && result.errors.length > 0) {
|
if (result.errors && result.errors.length > 0) {
|
||||||
throw result.errors.map(
|
// throw the first error is enough
|
||||||
error => new GraphQLError(error.message, error)
|
const firstError = result.errors[0];
|
||||||
);
|
throw new GraphQLError(firstError.message, firstError);
|
||||||
} else {
|
} else {
|
||||||
throw new GraphQLError('Empty GraphQL error body');
|
throw new GraphQLError('Empty GraphQL error body');
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user