diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 1dbb0f1a56623..e94e4bb16cabc 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -131,6 +131,11 @@ "ts-node/esm/transpile-only.mjs", "--es-module-specifier-resolution=node" ], + "watchMode": { + "ignoreChanges": [ + "**/*.gen.*" + ] + }, "files": [ "tests/**/*.spec.ts", "tests/**/*.e2e.ts" @@ -160,7 +165,8 @@ ], "ignore": [ "**/__tests__/**", - "**/dist/**" + "**/dist/**", + "*.gen.*" ], "env": { "TS_NODE_TRANSPILE_ONLY": true, diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts index 4d88fed68add3..f0ff5b68c2d00 100644 --- a/packages/backend/server/src/app.module.ts +++ b/packages/backend/server/src/app.module.ts @@ -27,6 +27,7 @@ import { ConfigModule, mergeConfigOverride, } from './fundamentals/config'; +import { ErrorModule } from './fundamentals/error'; import { EventModule } from './fundamentals/event'; import { GqlModule } from './fundamentals/graphql'; import { HelpersModule } from './fundamentals/helpers'; @@ -52,6 +53,7 @@ export const FunctionalityModules = [ MailModule, StorageProviderModule, HelpersModule, + ErrorModule, ]; function filterOptionalModule( diff --git a/packages/backend/server/src/core/auth/controller.ts b/packages/backend/server/src/core/auth/controller.ts index 47e1635d89ad5..cc2e1bc87d159 100644 --- a/packages/backend/server/src/core/auth/controller.ts +++ b/packages/backend/server/src/core/auth/controller.ts @@ -1,7 +1,6 @@ import { randomUUID } from 'node:crypto'; import { - BadRequestException, Body, Controller, Get, @@ -14,7 +13,16 @@ import { } from '@nestjs/common'; 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 { validators } from '../utils/validators'; import { CurrentUser } from './current-user'; @@ -55,9 +63,7 @@ export class AuthController { validators.assertValidEmail(credential.email); const canSignIn = await this.auth.canSignIn(credential.email); if (!canSignIn) { - throw new BadRequestException( - `You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information` - ); + throw new EarlyAccessRequired(); } if (credential.password) { @@ -74,7 +80,7 @@ export class AuthController { if (!user) { const allowSignup = await this.config.runtime.fetch('auth/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) { - throw new Error('Failed to send sign-in email.'); + throw new InternalServerError('Failed to send sign-in email.'); } res.status(HttpStatus.OK).send({ @@ -145,7 +151,7 @@ export class AuthController { @Body() { email, token }: MagicLinkCredential ) { if (!token || !email) { - throw new BadRequestException('Missing sign-in mail token'); + throw new EmailTokenNotFound(); } validators.assertValidEmail(email); @@ -155,7 +161,7 @@ export class AuthController { }); if (!valid) { - throw new BadRequestException('Invalid sign-in mail token'); + throw new InvalidEmailToken(); } const user = await this.user.fulfillUser(email, { diff --git a/packages/backend/server/src/core/auth/guard.ts b/packages/backend/server/src/core/auth/guard.ts index cded1c520805f..25679c40ece1f 100644 --- a/packages/backend/server/src/core/auth/guard.ts +++ b/packages/backend/server/src/core/auth/guard.ts @@ -3,15 +3,13 @@ import type { ExecutionContext, OnModuleInit, } from '@nestjs/common'; -import { - Injectable, - SetMetadata, - UnauthorizedException, - UseGuards, -} from '@nestjs/common'; +import { Injectable, SetMetadata, UseGuards } from '@nestjs/common'; import { ModuleRef, Reflector } from '@nestjs/core'; -import { getRequestResponseFromContext } from '../../fundamentals'; +import { + AuthenticationRequired, + getRequestResponseFromContext, +} from '../../fundamentals'; import { AuthService, parseAuthUserSeqNum } from './service'; function extractTokenFromHeader(authorization: string) { @@ -84,7 +82,7 @@ export class AuthGuard implements CanActivate, OnModuleInit { } if (!req.user) { - throw new UnauthorizedException('You are not signed in.'); + throw new AuthenticationRequired(); } return true; diff --git a/packages/backend/server/src/core/auth/resolver.ts b/packages/backend/server/src/core/auth/resolver.ts index 3cfa3ddbb6cc6..dab44d4f20467 100644 --- a/packages/backend/server/src/core/auth/resolver.ts +++ b/packages/backend/server/src/core/auth/resolver.ts @@ -1,4 +1,3 @@ -import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { Args, Field, @@ -10,7 +9,18 @@ import { Resolver, } 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 { UserType } from '../user/types'; import { validators } from '../utils/validators'; @@ -62,7 +72,7 @@ export class AuthResolver { @Parent() user: UserType ): Promise { if (user.id !== currentUser.id) { - throw new ForbiddenException('Invalid user'); + throw new ActionForbidden(); } const session = await this.auth.createUserSession( @@ -102,7 +112,7 @@ export class AuthResolver { ); if (!valid) { - throw new ForbiddenException('Invalid token'); + throw new InvalidEmailToken(); } await this.auth.changePassword(user.id, newPassword); @@ -124,7 +134,7 @@ export class AuthResolver { }); if (!valid) { - throw new ForbiddenException('Invalid token'); + throw new InvalidEmailToken(); } email = decodeURIComponent(email); @@ -144,7 +154,7 @@ export class AuthResolver { @Args('email', { nullable: true }) _email?: string ) { if (!user.emailVerified) { - throw new ForbiddenException('Please verify your email first.'); + throw new EmailVerificationRequired(); } const token = await this.token.createToken( @@ -166,7 +176,7 @@ export class AuthResolver { @Args('email', { nullable: true }) _email?: string ) { if (!user.emailVerified) { - throw new ForbiddenException('Please verify your email first.'); + throw new EmailVerificationRequired(); } const token = await this.token.createToken( @@ -195,7 +205,7 @@ export class AuthResolver { @Args('email', { nullable: true }) _email?: string ) { if (!user.emailVerified) { - throw new ForbiddenException('Please verify your email first.'); + throw new EmailVerificationRequired(); } const token = await this.token.createToken(TokenType.ChangeEmail, user.id); @@ -213,24 +223,26 @@ export class AuthResolver { @Args('email') email: string, @Args('callbackUrl') callbackUrl: string ) { + if (!token) { + throw new EmailTokenNotFound(); + } + validators.assertValidEmail(email); const valid = await this.token.verifyToken(TokenType.ChangeEmail, token, { credential: user.id, }); if (!valid) { - throw new ForbiddenException('Invalid token'); + throw new InvalidEmailToken(); } const hasRegistered = await this.user.findUserByEmail(email); if (hasRegistered) { if (hasRegistered.id !== user.id) { - throw new BadRequestException(`The email provided has been taken.`); + throw new EmailAlreadyUsed(); } else { - throw new BadRequestException( - `The email provided is the same as the current email.` - ); + throw new SameEmailProvided(); } } @@ -264,7 +276,7 @@ export class AuthResolver { @Args('token') token: string ) { if (!token) { - throw new BadRequestException('Invalid token'); + throw new EmailTokenNotFound(); } const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, { @@ -272,7 +284,7 @@ export class AuthResolver { }); if (!valid) { - throw new ForbiddenException('Invalid token'); + throw new InvalidEmailToken(); } const { emailVerifiedAt } = await this.auth.setEmailVerified(user.id); diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index 672250942273c..cff3790715971 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -1,16 +1,18 @@ -import { - BadRequestException, - Injectable, - NotAcceptableException, - OnApplicationBootstrap, -} from '@nestjs/common'; +import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import type { User } from '@prisma/client'; import { PrismaClient } from '@prisma/client'; import type { CookieOptions, Request, Response } from 'express'; 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 { QuotaService } from '../quota/service'; import { QuotaType } from '../quota/types'; @@ -109,7 +111,7 @@ export class AuthService implements OnApplicationBootstrap { const user = await this.user.findUserByEmail(email); if (user) { - throw new BadRequestException('Email was taken'); + throw new EmailAlreadyUsed(); } const hashedPassword = await this.crypto.encryptPassword(password); @@ -127,13 +129,11 @@ export class AuthService implements OnApplicationBootstrap { const user = await this.user.findUserWithHashedPasswordByEmail(email); if (!user) { - throw new NotAcceptableException('Invalid sign in credentials'); + throw new WrongSignInCredentials(); } if (!user.password) { - throw new NotAcceptableException( - 'User Password is not set. Should login through email link.' - ); + throw new WrongSignInMethod(); } const passwordMatches = await this.crypto.verifyPassword( @@ -142,7 +142,7 @@ export class AuthService implements OnApplicationBootstrap { ); if (!passwordMatches) { - throw new NotAcceptableException('Invalid sign in credentials'); + throw new WrongSignInCredentials(); } return sessionUser(user); @@ -382,27 +382,14 @@ export class AuthService implements OnApplicationBootstrap { id: string, newPassword: string ): Promise> { - const user = await this.user.findUserById(id); - - if (!user) { - throw new BadRequestException('Invalid email'); - } - const hashedPassword = await this.crypto.encryptPassword(newPassword); - - return this.user.updateUser(user.id, { password: hashedPassword }); + return this.user.updateUser(id, { password: hashedPassword }); } async changeEmail( id: string, newEmail: string ): Promise> { - const user = await this.user.findUserById(id); - - if (!user) { - throw new BadRequestException('Invalid email'); - } - return this.user.updateUser(id, { email: newEmail, emailVerifiedAt: new Date(), diff --git a/packages/backend/server/src/core/common/admin-guard.ts b/packages/backend/server/src/core/common/admin-guard.ts index 360675f708c08..20505e9aaa12e 100644 --- a/packages/backend/server/src/core/common/admin-guard.ts +++ b/packages/backend/server/src/core/common/admin-guard.ts @@ -3,10 +3,13 @@ import type { ExecutionContext, OnModuleInit, } from '@nestjs/common'; -import { Injectable, UnauthorizedException, UseGuards } from '@nestjs/common'; +import { Injectable, UseGuards } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; -import { getRequestResponseFromContext } from '../../fundamentals'; +import { + ActionForbidden, + getRequestResponseFromContext, +} from '../../fundamentals'; import { FeatureManagementService } from '../features'; @Injectable() @@ -27,7 +30,7 @@ export class AdminGuard implements CanActivate, OnModuleInit { } if (!allow) { - throw new UnauthorizedException('Your operation is not allowed.'); + throw new ActionForbidden(); } return true; diff --git a/packages/backend/server/src/core/doc/history.ts b/packages/backend/server/src/core/doc/history.ts index 0118280b61965..a4471440d0004 100644 --- a/packages/backend/server/src/core/doc/history.ts +++ b/packages/backend/server/src/core/doc/history.ts @@ -5,7 +5,14 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { PrismaClient } from '@prisma/client'; 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 { Permission } from '../workspaces/types'; import { isEmptyBuffer } from './manager'; @@ -191,7 +198,11 @@ export class DocHistoryManager { }); 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({ @@ -204,8 +215,7 @@ export class DocHistoryManager { }); if (!oldSnapshot) { - // unreachable actually - throw new Error('Given Doc not found'); + throw new DocNotFound({ workspaceId, docId: id }); } // save old snapshot as one history record @@ -236,8 +246,7 @@ export class DocHistoryManager { }); if (!permission) { - // unreachable actually - throw new Error('Workspace owner not found'); + throw new WorkspaceNotFound({ workspaceId }); } const quota = await this.quota.getUserQuota(permission.userId); diff --git a/packages/backend/server/src/core/features/resolver.ts b/packages/backend/server/src/core/features/resolver.ts index 65a4496eaf28c..cc983860ac530 100644 --- a/packages/backend/server/src/core/features/resolver.ts +++ b/packages/backend/server/src/core/features/resolver.ts @@ -1,4 +1,3 @@ -import { BadRequestException } from '@nestjs/common'; import { Args, Context, @@ -11,6 +10,7 @@ import { Resolver, } from '@nestjs/graphql'; +import { UserNotFound } from '../../fundamentals'; import { sessionUser } from '../auth/service'; import { Admin } from '../common'; import { UserService } from '../user/service'; @@ -59,7 +59,7 @@ export class FeatureManagementResolver { async removeEarlyAccess(@Args('email') email: string): Promise { const user = await this.users.findUserByEmail(email); if (!user) { - throw new BadRequestException(`User ${email} not found`); + throw new UserNotFound(); } return this.feature.removeEarlyAccess(user.id); } @@ -82,7 +82,7 @@ export class FeatureManagementResolver { const user = await this.users.findUserByEmail(email); if (!user) { - throw new BadRequestException(`User ${email} not found`); + throw new UserNotFound(); } await this.feature.addAdmin(user.id); diff --git a/packages/backend/server/src/core/quota/storage.ts b/packages/backend/server/src/core/quota/storage.ts index b15e7f8dc35b0..7724446ef6163 100644 --- a/packages/backend/server/src/core/quota/storage.ts +++ b/packages/backend/server/src/core/quota/storage.ts @@ -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 { WorkspaceBlobStorage } from '../storage'; import { PermissionService } from '../workspaces/permission'; @@ -115,7 +116,7 @@ export class QuotaManagementService { async getWorkspaceUsage(workspaceId: string): Promise { const { user: owner } = await this.permissions.getWorkspaceOwner(workspaceId); - if (!owner) throw new NotFoundException('Workspace owner not found'); + if (!owner) throw new WorkspaceOwnerNotFound({ workspaceId }); const { feature: { name, diff --git a/packages/backend/server/src/core/sync/events/error.ts b/packages/backend/server/src/core/sync/events/error.ts deleted file mode 100644 index ea6a3ac5e5faf..0000000000000 --- a/packages/backend/server/src/core/sync/events/error.ts +++ /dev/null @@ -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.` - ); - } -} diff --git a/packages/backend/server/src/core/sync/events/events.gateway.ts b/packages/backend/server/src/core/sync/events/events.gateway.ts index 516311205e0dc..78e66cee35e74 100644 --- a/packages/backend/server/src/core/sync/events/events.gateway.ts +++ b/packages/backend/server/src/core/sync/events/events.gateway.ts @@ -11,73 +11,36 @@ import { import { Server, Socket } from 'socket.io'; 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 { DocManager } from '../../doc'; import { DocID } from '../../utils/doc'; import { PermissionService } from '../../workspaces/permission'; 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) => applyDecorators( - GatewayErrorWrapper(), + GatewayErrorWrapper(event), CallTimer('socketio', 'event_duration', { event }), RawSubscribeMessage(event) ); -type EventResponse = - | { - error: EventError; +type EventResponse = Data extends never + ? { + data?: never; } - | (Data extends never - ? { - data?: never; - } - : { - data: Data; - }); + : { + data: Data; + }; function Sync(workspaceId: string): `${string}:sync` { return `${workspaceId}:sync`; @@ -133,10 +96,10 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { } is outdated, please update to ${AFFiNE.version}`, }); - throw new EventError( - EventErrorCode.VERSION_REJECTED, - `Client version ${version} is outdated, please update to ${AFFiNE.version}` - ); + throw new VersionRejected({ + version: version || 'unknown', + serverVersion: AFFiNE.version, + }); } } @@ -156,7 +119,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { assertInWorkspace(client: Socket, room: `${string}:${'sync' | 'awareness'}`) { 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 )) ) { - 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); if (!res) { - return { - error: new DocNotFoundError(workspaceId, docId.guid), - }; + throw new DocNotFound({ workspaceId, docId: docId.guid }); } const missing = Buffer.from( diff --git a/packages/backend/server/src/core/user/controller.ts b/packages/backend/server/src/core/user/controller.ts index 388ced7f9e2f0..6fc2e4bf5f83f 100644 --- a/packages/backend/server/src/core/user/controller.ts +++ b/packages/backend/server/src/core/user/controller.ts @@ -1,13 +1,7 @@ -import { - Controller, - ForbiddenException, - Get, - NotFoundException, - Param, - Res, -} from '@nestjs/common'; +import { Controller, Get, Param, Res } from '@nestjs/common'; import type { Response } from 'express'; +import { ActionForbidden, UserAvatarNotFound } from '../../fundamentals'; import { AvatarStorage } from '../storage'; @Controller('/api/avatars') @@ -17,7 +11,7 @@ export class UserAvatarController { @Get('/:id') async getAvatar(@Res() res: Response, @Param('id') id: string) { if (this.storage.provider.type !== 'fs') { - throw new ForbiddenException( + throw new ActionForbidden( 'Only available when avatar storage provider set to fs.' ); } @@ -25,7 +19,7 @@ export class UserAvatarController { const { body, metadata } = await this.storage.get(id); if (!body) { - throw new NotFoundException(`Avatar ${id} not found.`); + throw new UserAvatarNotFound(); } // metadata should always exists if body is not null diff --git a/packages/backend/server/src/core/user/resolver.ts b/packages/backend/server/src/core/user/resolver.ts index 88d3b06762453..8098752f7d63d 100644 --- a/packages/backend/server/src/core/user/resolver.ts +++ b/packages/backend/server/src/core/user/resolver.ts @@ -1,4 +1,3 @@ -import { BadRequestException } from '@nestjs/common'; import { Args, Field, @@ -18,6 +17,7 @@ import { CryptoHelper, type FileUpload, Throttle, + UserNotFound, } from '../../fundamentals'; import { CurrentUser } from '../auth/current-user'; import { Public } from '../auth/guard'; @@ -92,7 +92,7 @@ export class UserResolver { avatar: FileUpload ) { if (!user) { - throw new BadRequestException(`User not found`); + throw new UserNotFound(); } const avatarUrl = await this.storage.put( @@ -128,7 +128,7 @@ export class UserResolver { }) async removeAvatar(@CurrentUser() user: CurrentUser) { if (!user) { - throw new BadRequestException(`User not found`); + throw new UserNotFound(); } await this.users.updateUser(user.id, { avatarUrl: null }); return { success: true }; diff --git a/packages/backend/server/src/core/user/service.ts b/packages/backend/server/src/core/user/service.ts index e4c232723ff4a..1f70deb528380 100644 --- a/packages/backend/server/src/core/user/service.ts +++ b/packages/backend/server/src/core/user/service.ts @@ -1,8 +1,9 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { Prisma, PrismaClient } from '@prisma/client'; import { Config, + EmailAlreadyUsed, EventEmitter, type EventPayload, OnEvent, @@ -63,7 +64,7 @@ export class UserService { const user = await this.findUserByEmail(email); if (user) { - throw new BadRequestException('Email already exists'); + throw new EmailAlreadyUsed(); } return this.createUser({ diff --git a/packages/backend/server/src/core/utils/validators.ts b/packages/backend/server/src/core/utils/validators.ts index 6b5934b189224..e6d3aeeb36053 100644 --- a/packages/backend/server/src/core/utils/validators.ts +++ b/packages/backend/server/src/core/utils/validators.ts @@ -1,36 +1,23 @@ -import { BadRequestException } from '@nestjs/common'; import z from 'zod'; -function assertValid(z: z.ZodType, value: unknown) { - const result = z.safeParse(value); +import { InvalidEmail, InvalidPasswordLength } from '../../fundamentals'; +export function assertValidEmail(email: string) { + const result = z.string().email().safeParse(email); if (!result.success) { - const firstIssue = result.error.issues.at(0); - if (firstIssue) { - throw new BadRequestException(firstIssue.message); - } else { - throw new BadRequestException('Invalid credential'); - } + throw new InvalidEmail(); } } -export function assertValidEmail(email: string) { - assertValid(z.string().email({ message: 'Invalid email address' }), email); -} - export function assertValidPassword( password: string, { min, max }: { min: number; max: number } ) { - assertValid( - z - .string() - .min(min, { message: `Password must be ${min} or more charactors long` }) - .max(max, { - message: `Password must be ${max} or fewer charactors long`, - }), - password - ); + const result = z.string().min(min).max(max).safeParse(password); + + if (!result.success) { + throw new InvalidPasswordLength({ min, max }); + } } export const validators = { diff --git a/packages/backend/server/src/core/workspaces/controller.ts b/packages/backend/server/src/core/workspaces/controller.ts index b0bd3e65a87fb..acb6a1cfbde4f 100644 --- a/packages/backend/server/src/core/workspaces/controller.ts +++ b/packages/backend/server/src/core/workspaces/controller.ts @@ -1,16 +1,16 @@ -import { - Controller, - ForbiddenException, - Get, - Logger, - NotFoundException, - Param, - Res, -} from '@nestjs/common'; +import { Controller, Get, Logger, Param, Res } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; 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 { DocHistoryManager, DocManager } from '../doc'; import { WorkspaceBlobStorage } from '../storage'; @@ -50,15 +50,16 @@ export class WorkspacesController { user?.id )) ) { - throw new ForbiddenException('Permission denied'); + throw new ActionForbidden(); } const { body, metadata } = await this.storage.get(workspaceId, name); if (!body) { - throw new NotFoundException( - `Blob not found in workspace ${workspaceId}: ${name}` - ); + throw new BlobNotFound({ + workspaceId, + blobId: name, + }); } // metadata should always exists if body is not null @@ -93,7 +94,7 @@ export class WorkspacesController { user?.id )) ) { - throw new ForbiddenException('Permission denied'); + throw new AccessDenied(); } const binResponse = await this.docManager.getBinary( @@ -102,7 +103,10 @@ export class WorkspacesController { ); if (!binResponse) { - throw new NotFoundException('Doc not found'); + throw new DocNotFound({ + workspaceId: docId.workspace, + docId: docId.guid, + }); } if (!docId.isWorkspace) { @@ -139,7 +143,7 @@ export class WorkspacesController { try { ts = new Date(timestamp); } catch (e) { - throw new Error('Invalid timestamp'); + throw new InvalidHistoryTimestamp({ timestamp }); } await this.permission.checkPagePermission( @@ -160,7 +164,11 @@ export class WorkspacesController { res.setHeader('cache-control', 'private, max-age=2592000, immutable'); res.send(history.blob); } else { - throw new NotFoundException('Doc history not found'); + throw new DocHistoryNotFound({ + workspaceId: docId.workspace, + docId: guid, + timestamp: ts.getTime(), + }); } } } diff --git a/packages/backend/server/src/core/workspaces/management.ts b/packages/backend/server/src/core/workspaces/management.ts index f171ddea943fc..e4e230f9481a9 100644 --- a/packages/backend/server/src/core/workspaces/management.ts +++ b/packages/backend/server/src/core/workspaces/management.ts @@ -1,4 +1,3 @@ -import { ForbiddenException } from '@nestjs/common'; import { Args, Int, @@ -9,6 +8,7 @@ import { Resolver, } from '@nestjs/graphql'; +import { ActionForbidden } from '../../fundamentals'; import { CurrentUser } from '../auth'; import { Admin } from '../common'; import { FeatureManagementService, FeatureType } from '../features'; @@ -56,13 +56,13 @@ export class WorkspaceManagementResolver { @Args('enable') enable: boolean ): Promise { 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 availableFeatures = await this.availableFeatures(user); if (owner.user.id !== user.id || !availableFeatures.includes(feature)) { - throw new ForbiddenException('You are not allowed to do this'); + throw new ActionForbidden(); } if (enable) { diff --git a/packages/backend/server/src/core/workspaces/permission.ts b/packages/backend/server/src/core/workspaces/permission.ts index 47d30b24518b4..4fe95e2c1f72c 100644 --- a/packages/backend/server/src/core/workspaces/permission.ts +++ b/packages/backend/server/src/core/workspaces/permission.ts @@ -1,7 +1,8 @@ -import { ForbiddenException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import type { Prisma } from '@prisma/client'; import { PrismaClient } from '@prisma/client'; +import { DocAccessDenied, WorkspaceAccessDenied } from '../../fundamentals'; import { Permission } from './types'; export enum PublicPageMode { @@ -151,7 +152,7 @@ export class PermissionService { permission: Permission = Permission.Read ) { 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 ) { if (!(await this.tryCheckPage(ws, page, user, permission))) { - throw new ForbiddenException('Permission denied'); + throw new DocAccessDenied({ workspaceId: ws, docId: page }); } } diff --git a/packages/backend/server/src/core/workspaces/resolvers/blob.ts b/packages/backend/server/src/core/workspaces/resolvers/blob.ts index 9717dddb7cd5b..cd55d3f112c04 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/blob.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/blob.ts @@ -1,4 +1,4 @@ -import { Logger, PayloadTooLargeException, UseGuards } from '@nestjs/common'; +import { Logger, UseGuards } from '@nestjs/common'; import { Args, Int, @@ -13,6 +13,7 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import type { FileUpload } from '../../../fundamentals'; import { + BlobQuotaExceeded, CloudThrottlerGuard, MakeCache, PreventCache, @@ -126,10 +127,9 @@ export class WorkspaceBlobResolver { const checkExceeded = await this.quota.getQuotaCalculatorByWorkspace(workspaceId); + // TODO(@darksky): need a proper way to separate `BlobQuotaExceeded` and `BlobSizeTooLarge` if (checkExceeded(0)) { - throw new PayloadTooLargeException( - 'Storage or blob size limit exceeded.' - ); + throw new BlobQuotaExceeded(); } const buffer = await new Promise((resolve, reject) => { const stream = blob.createReadStream(); @@ -140,9 +140,7 @@ export class WorkspaceBlobResolver { // check size after receive each chunk to avoid unnecessary memory usage const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0); if (checkExceeded(bufferSize)) { - reject( - new PayloadTooLargeException('Storage or blob size limit exceeded.') - ); + reject(new BlobQuotaExceeded()); } }); stream.on('error', reject); @@ -150,7 +148,7 @@ export class WorkspaceBlobResolver { const buffer = Buffer.concat(chunks); if (checkExceeded(buffer.length)) { - reject(new PayloadTooLargeException('Storage limit exceeded.')); + reject(new BlobQuotaExceeded()); } else { resolve(buffer); } diff --git a/packages/backend/server/src/core/workspaces/resolvers/history.ts b/packages/backend/server/src/core/workspaces/resolvers/history.ts index 9b3741c6fcd2d..3128210744583 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/history.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/history.ts @@ -47,10 +47,6 @@ export class DocHistoryResolver { ): Promise { const docId = new DocID(guid, workspace.id); - if (docId.isWorkspace) { - throw new Error('Invalid guid for listing doc histories.'); - } - return this.historyManager .list(workspace.id, docId.guid, timestamp, take) .then(rows => @@ -73,10 +69,6 @@ export class DocHistoryResolver { ): Promise { const docId = new DocID(guid, workspaceId); - if (docId.isWorkspace) { - throw new Error('Invalid guid for recovering doc from history.'); - } - await this.permission.checkPagePermission( docId.workspace, docId.guid, diff --git a/packages/backend/server/src/core/workspaces/resolvers/page.ts b/packages/backend/server/src/core/workspaces/resolvers/page.ts index 4dcb69b077766..c9177a4d2e8a4 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/page.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/page.ts @@ -1,4 +1,3 @@ -import { BadRequestException } from '@nestjs/common'; import { Args, Field, @@ -12,6 +11,11 @@ import { import type { WorkspacePage as PrismaWorkspacePage } from '@prisma/client'; import { PrismaClient } from '@prisma/client'; +import { + ExpectToPublishPage, + ExpectToRevokePublicPage, + PageIsNotPublic, +} from '../../../fundamentals'; import { CurrentUser } from '../../auth'; import { DocID } from '../../utils/doc'; import { PermissionService, PublicPageMode } from '../permission'; @@ -126,7 +130,7 @@ export class PagePermissionResolver { const docId = new DocID(pageId, workspaceId); if (docId.isWorkspace) { - throw new BadRequestException('Expect page not to be workspace'); + throw new ExpectToPublishPage(); } await this.permission.checkWorkspace( @@ -163,7 +167,7 @@ export class PagePermissionResolver { const docId = new DocID(pageId, workspaceId); 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( @@ -178,7 +182,7 @@ export class PagePermissionResolver { ); if (!isPublic) { - throw new BadRequestException('Page is not public'); + throw new PageIsNotPublic('Page is not public'); } return this.permission.revokePublicPage(docId.workspace, docId.guid); diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 9bf0bdbba3e76..5bd6be71172f2 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -1,10 +1,4 @@ -import { - ForbiddenException, - InternalServerErrorException, - Logger, - NotFoundException, - PayloadTooLargeException, -} from '@nestjs/common'; +import { Logger } from '@nestjs/common'; import { Args, Int, @@ -21,11 +15,18 @@ import { applyUpdate, Doc } from 'yjs'; import type { FileUpload } from '../../../fundamentals'; import { + CantChangeWorkspaceOwner, EventEmitter, + InternalServerError, MailService, + MemberQuotaExceeded, MutexService, Throttle, - TooManyRequestsException, + TooManyRequest, + UserNotFound, + WorkspaceAccessDenied, + WorkspaceNotFound, + WorkspaceOwnerNotFound, } from '../../../fundamentals'; import { CurrentUser, Public } from '../../auth'; import { QuotaManagementService, QuotaQueryType } from '../../quota'; @@ -77,7 +78,7 @@ export class WorkspaceResolver { const permission = await this.permissions.get(workspace.id, user.id); if (!permission) { - throw new ForbiddenException(); + throw new WorkspaceAccessDenied({ workspaceId: workspace.id }); } return permission; @@ -196,7 +197,7 @@ export class WorkspaceResolver { const workspace = await this.prisma.workspace.findUnique({ where: { id } }); if (!workspace) { - throw new NotFoundException("Workspace doesn't exist"); + throw new WorkspaceNotFound({ workspaceId: id }); } return workspace; @@ -307,7 +308,7 @@ export class WorkspaceResolver { ); if (permission === Permission.Owner) { - throw new ForbiddenException('Cannot change owner'); + throw new CantChangeWorkspaceOwner(); } try { @@ -315,7 +316,7 @@ export class WorkspaceResolver { const lockFlag = `invite:${workspaceId}`; await using lock = await this.mutex.lock(lockFlag); if (!lock) { - return new TooManyRequestsException('Server is busy'); + return new TooManyRequest(); } // member limit check @@ -326,7 +327,7 @@ export class WorkspaceResolver { this.quota.getWorkspaceUsage(workspaceId), ]); if (memberCount >= quota.memberLimit) { - return new PayloadTooLargeException('Workspace member limit reached.'); + return new MemberQuotaExceeded(); } 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}` ); } - return new InternalServerErrorException( + throw new InternalServerError( 'Failed to send invite email. Please try again.' ); } @@ -389,7 +390,7 @@ export class WorkspaceResolver { return inviteId; } catch (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); if (!inviter || !invitee) { - throw new ForbiddenException( - `can not find inviter/invitee by inviteId: ${inviteId}` - ); + throw new UserNotFound(); } if (sendAcceptMail) { @@ -508,9 +507,7 @@ export class WorkspaceResolver { const owner = await this.permissions.getWorkspaceOwner(workspaceId); if (!owner.user) { - throw new ForbiddenException( - `can not find owner by workspaceId: ${workspaceId}` - ); + throw new WorkspaceOwnerNotFound({ workspaceId: workspaceId }); } if (sendLeaveMail) { diff --git a/packages/backend/server/src/fundamentals/config/runtime/service.ts b/packages/backend/server/src/fundamentals/config/runtime/service.ts index f6048af7de112..66eed7717e49a 100644 --- a/packages/backend/server/src/fundamentals/config/runtime/service.ts +++ b/packages/backend/server/src/fundamentals/config/runtime/service.ts @@ -1,5 +1,4 @@ import { - BadRequestException, forwardRef, Inject, Injectable, @@ -10,6 +9,7 @@ import { PrismaClient } from '@prisma/client'; import { difference, keyBy } from 'lodash-es'; import { Cache } from '../../cache'; +import { InvalidRuntimeConfigType, RuntimeConfigNotFound } from '../../error'; import { defer } from '../../utils/promise'; import { defaultRuntimeConfig, runtimeConfigType } from '../register'; import { AppRuntimeConfigModules, FlattenedAppRuntimeConfig } from '../types'; @@ -21,15 +21,17 @@ function validateConfigType( const config = defaultRuntimeConfig[key]; if (!config) { - throw new BadRequestException(`Unknown runtime config key '${key}'`); + throw new RuntimeConfigNotFound({ key }); } const want = config.type; const get = runtimeConfigType(value); if (get !== want) { - throw new BadRequestException( - `Invalid runtime config type for '${key}', want '${want}', but get '${get}'` - ); + throw new InvalidRuntimeConfigType({ + key, + want, + get, + }); } } @@ -68,7 +70,7 @@ export class Runtime implements OnApplicationBootstrap { const dbValue = await this.loadDb(k); if (dbValue === undefined) { - throw new Error(`Runtime config ${k} not found`); + throw new RuntimeConfigNotFound({ key: k }); } await this.setCache(k, dbValue); diff --git a/packages/backend/server/src/fundamentals/error/def.ts b/packages/backend/server/src/fundamentals/error/def.ts new file mode 100644 index 0000000000000..f1c060a62c2eb --- /dev/null +++ b/packages/backend/server/src/fundamentals/error/def.ts @@ -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>; + +export type UserFriendlyErrorOptions = { + type: UserFriendlyErrorBaseType; + args?: ErrorArgs; + message: string | ((args: any) => string); +}; + +const BaseTypeToHttpStatusMap: Record = { + 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; diff --git a/packages/backend/server/src/fundamentals/error/errors.gen.ts b/packages/backend/server/src/fundamentals/error/errors.gen.ts new file mode 100644 index 0000000000000..6c608cd2cee9d --- /dev/null +++ b/packages/backend/server/src/fundamentals/error/errors.gen.ts @@ -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, +}); diff --git a/packages/backend/server/src/fundamentals/error/index.ts b/packages/backend/server/src/fundamentals/error/index.ts index 71ecd22a46462..7859a2f865404 100644 --- a/packages/backend/server/src/fundamentals/error/index.ts +++ b/packages/backend/server/src/fundamentals/error/index.ts @@ -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 './too-many-requests'; diff --git a/packages/backend/server/src/fundamentals/graphql/index.ts b/packages/backend/server/src/fundamentals/graphql/index.ts index a4f10609d6331..58def77b9038a 100644 --- a/packages/backend/server/src/fundamentals/graphql/index.ts +++ b/packages/backend/server/src/fundamentals/graphql/index.ts @@ -1,16 +1,18 @@ import './config'; +import { STATUS_CODES } from 'node:http'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { ApolloDriverConfig } 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 { Request, Response } from 'express'; import { GraphQLError } from 'graphql'; import { Config } from '../config'; +import { UserFriendlyError } from '../error'; import { GQLLoggerPlugin } from './logger-plugin'; export type GraphqlContext = { @@ -57,25 +59,20 @@ export type GraphqlContext = { if ( error instanceof GraphQLError && - error.originalError instanceof HttpException + error.originalError instanceof UserFriendlyError ) { - const statusCode = error.originalError.getStatus(); - const statusName = HttpStatus[statusCode]; - - // originally be 'INTERNAL_SERVER_ERROR' - formattedError.extensions['code'] = statusCode; - formattedError.extensions['status'] = statusName; - delete formattedError.extensions['originalError']; - + // @ts-expect-error allow assign + formattedError.extensions = error.originalError.json(); + formattedError.extensions.stacktrace = error.originalError.stack; return formattedError; } else { // @ts-expect-error allow assign formattedError.message = 'Internal Server Error'; - formattedError.extensions['code'] = - HttpStatus.INTERNAL_SERVER_ERROR; formattedError.extensions['status'] = - HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR]; + HttpStatus.INTERNAL_SERVER_ERROR; + formattedError.extensions['code'] = + STATUS_CODES[HttpStatus.INTERNAL_SERVER_ERROR]; } return formattedError; diff --git a/packages/backend/server/src/fundamentals/graphql/logger-plugin.ts b/packages/backend/server/src/fundamentals/graphql/logger-plugin.ts index af52dc54a5ceb..9bab7c0a205de 100644 --- a/packages/backend/server/src/fundamentals/graphql/logger-plugin.ts +++ b/packages/backend/server/src/fundamentals/graphql/logger-plugin.ts @@ -4,10 +4,10 @@ import { GraphQLRequestListener, } from '@apollo/server'; import { Plugin } from '@nestjs/apollo'; -import { HttpException, Logger } from '@nestjs/common'; import { Response } from 'express'; import { metrics } from '../metrics/metrics'; +import { mapAnyError } from '../nestjs'; export interface RequestContext { req: Express.Request & { @@ -17,8 +17,6 @@ export interface RequestContext { @Plugin() export class GQLLoggerPlugin implements ApolloServerPlugin { - protected logger = new Logger(GQLLoggerPlugin.name); - requestDidStart( ctx: GraphQLRequestContext ): Promise>> { @@ -39,30 +37,15 @@ export class GQLLoggerPlugin implements ApolloServerPlugin { return Promise.resolve(); }, didEncounterErrors: ctx => { - metrics.gql.counter('query_error_counter').add(1, { operation }); - - ctx.errors.forEach(err => { - // only log non-user errors - let msg: string | undefined; - - 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); - } + ctx.errors.forEach(gqlErr => { + const error = mapAnyError( + gqlErr.originalError ? gqlErr.originalError : gqlErr + ); + error.log('GraphQL'); + + metrics.gql + .counter('query_error_counter') + .add(1, { operation, code: error.status }); }); return Promise.resolve(); diff --git a/packages/backend/server/src/fundamentals/index.ts b/packages/backend/server/src/fundamentals/index.ts index 6404ac647606b..98059cefc8a1a 100644 --- a/packages/backend/server/src/fundamentals/index.ts +++ b/packages/backend/server/src/fundamentals/index.ts @@ -21,8 +21,11 @@ export { MailService } from './mailer'; export { CallCounter, CallTimer, metrics } from './metrics'; export { type ILocker, Lock, Locker, MutexService } from './mutex'; export { + GatewayErrorWrapper, getOptionalModuleMetadata, GlobalExceptionFilter, + mapAnyError, + mapSseError, OptionalModule, } from './nestjs'; export type { PrismaTransaction } from './prisma'; diff --git a/packages/backend/server/src/fundamentals/mailer/mail.service.ts b/packages/backend/server/src/fundamentals/mailer/mail.service.ts index b7831e1a1b56a..63b1306adda85 100644 --- a/packages/backend/server/src/fundamentals/mailer/mail.service.ts +++ b/packages/backend/server/src/fundamentals/mailer/mail.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable, Optional } from '@nestjs/common'; import { Config } from '../config'; +import { MailerServiceIsNotConfigured } from '../error'; import { URLHelper } from '../helpers'; import type { MailerService, Options } from './mailer'; import { MAILER_SERVICE } from './mailer'; @@ -15,7 +16,7 @@ export class MailService { async sendMail(options: Options) { if (!this.mailer) { - throw new Error('Mailer service is not configured.'); + throw new MailerServiceIsNotConfigured(); } return this.mailer.sendMail({ diff --git a/packages/backend/server/src/fundamentals/metrics/metrics.ts b/packages/backend/server/src/fundamentals/metrics/metrics.ts index 92ff1ecdcc069..a0ee8687a5d3d 100644 --- a/packages/backend/server/src/fundamentals/metrics/metrics.ts +++ b/packages/backend/server/src/fundamentals/metrics/metrics.ts @@ -34,7 +34,8 @@ export type KnownMetricScopes = | 'jwst' | 'auth' | 'controllers' - | 'doc'; + | 'doc' + | 'sse'; const metricCreators: MetricCreators = { counter(meter: Meter, name: string, opts?: MetricOptions) { diff --git a/packages/backend/server/src/fundamentals/nestjs/exception.ts b/packages/backend/server/src/fundamentals/nestjs/exception.ts index 231d983361623..a5e80b221ef93 100644 --- a/packages/backend/server/src/fundamentals/nestjs/exception.ts +++ b/packages/backend/server/src/fundamentals/nestjs/exception.ts @@ -1,25 +1,87 @@ -import { ArgumentsHost, Catch, HttpException } from '@nestjs/common'; +import { ArgumentsHost, Catch, Logger } from '@nestjs/common'; import { BaseExceptionFilter } from '@nestjs/core'; import { GqlContextType } from '@nestjs/graphql'; +import { ThrottlerException } from '@nestjs/throttler'; 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() export class GlobalExceptionFilter extends BaseExceptionFilter { + logger = new Logger('GlobalExceptionFilter'); override catch(exception: Error, host: ArgumentsHost) { + const error = mapAnyError(exception); // with useGlobalFilters, the context is always HTTP - if (host.getType() === 'graphql') { // let Graphql LoggerPlugin handle it // see '../graphql/logger-plugin.ts' - throw exception; + throw error; } else { - if (exception instanceof HttpException) { - const res = host.switchToHttp().getResponse(); - res.status(exception.getStatus()).send(exception.getResponse()); - return; - } else { - super.catch(exception, host); - } + error.log('HTTP'); + metrics.controllers.counter('error').add(1, { status: error.status }); + const res = host.switchToHttp().getResponse(); + res.status(error.status).send(error.json()); + return; } } } + +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(), + }); +} diff --git a/packages/backend/server/src/plugins/copilot/controller.ts b/packages/backend/server/src/plugins/copilot/controller.ts index 9bbe6c1a64bab..c4af749598bc5 100644 --- a/packages/backend/server/src/plugins/copilot/controller.ts +++ b/packages/backend/server/src/plugins/copilot/controller.ts @@ -1,11 +1,7 @@ import { - BadRequestException, Controller, Get, - HttpException, - InternalServerErrorException, Logger, - NotFoundException, Param, Query, Req, @@ -23,14 +19,21 @@ import { merge, mergeMap, Observable, - of, switchMap, toArray, } from 'rxjs'; import { Public } from '../../core/auth'; 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 { ChatSession, ChatSessionService } from './session'; import { CopilotStorage } from './storage'; @@ -40,7 +43,7 @@ import { CopilotWorkflowService } from './workflow'; export interface ChatEvent { type: 'attachment' | 'message' | 'error'; id?: string; - data: string; + data: string | object; } type CheckResult = { @@ -68,7 +71,7 @@ export class CopilotController { await this.chatSession.checkQuota(userId); const session = await this.chatSession.get(sessionId); if (!session || session.config.userId !== userId) { - throw new BadRequestException('Session not found'); + throw new CopilotSessionNotFound(); } const ret: CheckResult = { model: session.model }; @@ -104,7 +107,7 @@ export class CopilotController { ); } if (!provider) { - throw new InternalServerErrorException('No provider available'); + throw new NoCopilotProviderAvailable(); } return provider; @@ -116,7 +119,7 @@ export class CopilotController { ): Promise { const session = await this.chatSession.get(sessionId); if (!session) { - throw new BadRequestException('Session not found'); + throw new CopilotSessionNotFound(); } if (messageId) { @@ -148,20 +151,6 @@ export class CopilotController { 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') async chat( @CurrentUser() user: CurrentUser, @@ -200,9 +189,7 @@ export class CopilotController { return content; } catch (e: any) { - throw new InternalServerErrorException( - e.message || "Couldn't generate text" - ); + throw new CopilotFailedToGenerateText(e.message); } } @@ -253,18 +240,10 @@ export class CopilotController { ) ) ), - catchError(err => - of({ - type: 'error' as const, - data: this.handleError(err), - }) - ) + catchError(mapSseError) ); } catch (err) { - return of({ - type: 'error' as const, - data: this.handleError(err), - }); + return mapSseError(err); } } @@ -318,18 +297,10 @@ export class CopilotController { ) ) ), - catchError(err => - of({ - type: 'error' as const, - data: this.handleError(err), - }) - ) + catchError(mapSseError) ); } catch (err) { - return of({ - type: 'error' as const, - data: this.handleError(err), - }); + return mapSseError(err); } } @@ -356,7 +327,7 @@ export class CopilotController { model ); if (!provider) { - throw new InternalServerErrorException('No provider available'); + throw new NoCopilotProviderAvailable(); } const session = await this.appendSessionMessage(sessionId, messageId); @@ -402,18 +373,10 @@ export class CopilotController { ) ) ), - catchError(err => - of({ - type: 'error' as const, - data: this.handleError(err), - }) - ) + catchError(mapSseError) ); } catch (err) { - return of({ - type: 'error' as const, - data: this.handleError(err), - }); + return mapSseError(err); } } @@ -425,7 +388,7 @@ export class CopilotController { ) { const { unsplashKey } = this.config.plugins.copilot || {}; if (!unsplashKey) { - throw new InternalServerErrorException('Unsplash key is not configured'); + throw new UnsplashIsNotConfigured(); } const query = new URLSearchParams(params); @@ -458,9 +421,10 @@ export class CopilotController { const { body, metadata } = await this.storage.get(userId, workspaceId, key); if (!body) { - throw new NotFoundException( - `Blob not found in ${userId}'s workspace ${workspaceId}: ${key}` - ); + throw new BlobNotFound({ + workspaceId, + blobId: key, + }); } // metadata should always exists if body is not null diff --git a/packages/backend/server/src/plugins/copilot/resolver.ts b/packages/backend/server/src/plugins/copilot/resolver.ts index 3d45cfd4f89c6..d8c53e60f2e92 100644 --- a/packages/backend/server/src/plugins/copilot/resolver.ts +++ b/packages/backend/server/src/plugins/copilot/resolver.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; -import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Args, Field, @@ -23,6 +23,7 @@ import { Admin } from '../../core/common'; import { UserType } from '../../core/user'; import { PermissionService } from '../../core/workspaces/permission'; import { + CopilotFailedToCreateMessage, FileUpload, MutexService, Throttle, @@ -201,8 +202,6 @@ export class CopilotType { @Throttle() @Resolver(() => CopilotType) export class CopilotResolver { - private readonly logger = new Logger(CopilotResolver.name); - constructor( private readonly permissions: PermissionService, private readonly mutex: MutexService, @@ -385,8 +384,7 @@ export class CopilotResolver { try { return await this.chatSession.createMessage(options); } catch (e: any) { - this.logger.error(`Failed to create chat message: ${e.message}`); - throw new Error('Failed to create chat message'); + throw new CopilotFailedToCreateMessage(e.message); } } } diff --git a/packages/backend/server/src/plugins/copilot/session.ts b/packages/backend/server/src/plugins/copilot/session.ts index ba1d1fd0d8515..431de14b93ca3 100644 --- a/packages/backend/server/src/plugins/copilot/session.ts +++ b/packages/backend/server/src/plugins/copilot/session.ts @@ -5,7 +5,14 @@ import { AiPromptRole, PrismaClient } from '@prisma/client'; import { FeatureManagementService } from '../../core/features'; import { QuotaService } from '../../core/quota'; -import { PaymentRequiredException } from '../../fundamentals'; +import { + CopilotActionTaken, + CopilotMessageNotFound, + CopilotPromptNotFound, + CopilotQuotaExceeded, + CopilotSessionDeleted, + CopilotSessionNotFound, +} from '../../fundamentals'; import { ChatMessageCache } from './message'; import { PromptService } from './prompt'; import { @@ -58,7 +65,7 @@ export class ChatSession implements AsyncDisposable { this.state.messages.length > 0 && message.role === 'user' ) { - throw new Error('Action has been taken, no more messages allowed'); + throw new CopilotActionTaken(); } this.state.messages.push(message); this.stashMessageCount += 1; @@ -74,7 +81,7 @@ export class ChatSession implements AsyncDisposable { async getMessageById(messageId: string) { const message = await this.messageCache.get(messageId); if (!message || message.sessionId !== this.state.sessionId) { - throw new Error(`Message not found: ${messageId}`); + throw new CopilotMessageNotFound(); } return message; } @@ -82,7 +89,7 @@ export class ChatSession implements AsyncDisposable { async pushByMessageId(messageId: string) { const message = await this.messageCache.get(messageId); if (!message || message.sessionId !== this.state.sessionId) { - throw new Error(`Message not found: ${messageId}`); + throw new CopilotMessageNotFound(); } this.push({ @@ -196,7 +203,7 @@ export class ChatSessionService { }, select: { id: true, deletedAt: true }, })) || {}; - if (deletedAt) throw new Error(`Session is deleted: ${id}`); + if (deletedAt) throw new CopilotSessionDeleted(); if (id) sessionId = id; } @@ -274,7 +281,8 @@ export class ChatSessionService { .then(async session => { if (!session) return; 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); @@ -300,7 +308,7 @@ export class ChatSessionService { }) .then(session => session?.id); if (!id) { - throw new Error(`Session not found: ${sessionId}`); + throw new CopilotSessionNotFound(); } const ids = await tx.aiSessionMessage .findMany({ @@ -412,7 +420,7 @@ export class ChatSessionService { if (ret.success) { const prompt = await this.prompt.get(promptName); if (!prompt) { - throw new Error(`Prompt not found: ${promptName}`); + throw new CopilotPromptNotFound({ name: promptName }); } // render system prompt @@ -471,9 +479,7 @@ export class ChatSessionService { async checkQuota(userId: string) { const { limit, used } = await this.getQuota(userId); if (limit && Number.isFinite(limit) && used >= limit) { - throw new PaymentRequiredException( - `You have reached the limit of actions in this workspace, please upgrade your plan.` - ); + throw new CopilotQuotaExceeded(); } } @@ -482,7 +488,7 @@ export class ChatSessionService { const prompt = await this.prompt.get(options.promptName); if (!prompt) { this.logger.error(`Prompt not found: ${options.promptName}`); - throw new Error('Prompt not found'); + throw new CopilotPromptNotFound({ name: options.promptName }); } return await this.setSession({ ...options, diff --git a/packages/backend/server/src/plugins/copilot/storage.ts b/packages/backend/server/src/plugins/copilot/storage.ts index 44be26cd4cc86..cc8ff7a44fcc7 100644 --- a/packages/backend/server/src/plugins/copilot/storage.ts +++ b/packages/backend/server/src/plugins/copilot/storage.ts @@ -1,10 +1,11 @@ import { createHash } from 'node:crypto'; -import { Injectable, PayloadTooLargeException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { QuotaManagementService } from '../../core/quota'; import { type BlobInputType, + BlobQuotaExceeded, Config, type FileUpload, type StorageProvider, @@ -54,9 +55,7 @@ export class CopilotStorage { const checkExceeded = await this.quota.getQuotaCalculator(userId); if (checkExceeded(0)) { - throw new PayloadTooLargeException( - 'Storage or blob size limit exceeded.' - ); + throw new BlobQuotaExceeded(); } const buffer = await new Promise((resolve, reject) => { const stream = blob.createReadStream(); @@ -67,9 +66,7 @@ export class CopilotStorage { // check size after receive each chunk to avoid unnecessary memory usage const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0); if (checkExceeded(bufferSize)) { - reject( - new PayloadTooLargeException('Storage or blob size limit exceeded.') - ); + reject(new BlobQuotaExceeded()); } }); stream.on('error', reject); @@ -77,7 +74,7 @@ export class CopilotStorage { const buffer = Buffer.concat(chunks); if (checkExceeded(buffer.length)) { - reject(new PayloadTooLargeException('Storage limit exceeded.')); + reject(new BlobQuotaExceeded()); } else { resolve(buffer); } diff --git a/packages/backend/server/src/plugins/oauth/controller.ts b/packages/backend/server/src/plugins/oauth/controller.ts index 7007f9c770afd..8c28e77cd145f 100644 --- a/packages/backend/server/src/plugins/oauth/controller.ts +++ b/packages/backend/server/src/plugins/oauth/controller.ts @@ -1,17 +1,18 @@ -import { - BadRequestException, - Controller, - Get, - Query, - Req, - Res, -} from '@nestjs/common'; +import { Controller, Get, Query, Req, Res } from '@nestjs/common'; import { ConnectedAccount, PrismaClient } from '@prisma/client'; import type { Request, Response } from 'express'; import { AuthService, Public } from '../../core/auth'; import { UserService } from '../../core/user'; -import { URLHelper } from '../../fundamentals'; +import { + InvalidOauthCallbackState, + MissingOauthQueryParameter, + OauthAccountAlreadyConnected, + OauthStateExpired, + UnknownOauthProvider, + URLHelper, + WrongSignInMethod, +} from '../../fundamentals'; import { OAuthProviderName } from './config'; import { OAuthAccount, Tokens } from './providers/def'; import { OAuthProviderFactory } from './register'; @@ -35,12 +36,15 @@ export class OAuthController { @Query('provider') unknownProviderName: string, @Query('redirect_uri') redirectUri?: string ) { + if (!unknownProviderName) { + throw new MissingOauthQueryParameter({ name: 'provider' }); + } // @ts-expect-error safe const providerName = OAuthProviderName[unknownProviderName]; const provider = this.providerFactory.get(providerName); if (!provider) { - throw new BadRequestException('Invalid OAuth provider'); + throw new UnknownOauthProvider({ name: unknownProviderName }); } const state = await this.oauth.saveOAuthState({ @@ -60,29 +64,31 @@ export class OAuthController { @Query('state') stateStr?: string ) { if (!code) { - throw new BadRequestException('Missing query parameter `code`'); + throw new MissingOauthQueryParameter({ name: 'code' }); } 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); if (!state) { - throw new BadRequestException('OAuth state expired, please try again.'); + throw new OauthStateExpired(); } if (!state.provider) { - throw new BadRequestException( - 'Missing callback state parameter `provider`' - ); + throw new MissingOauthQueryParameter({ name: 'provider' }); } const provider = this.providerFactory.get(state.provider); if (!provider) { - throw new BadRequestException('Invalid provider'); + throw new UnknownOauthProvider({ name: state.provider ?? 'unknown' }); } 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. // let user manually connect in account sessions instead. if (user.registered) { - throw new BadRequestException( - 'The account with provided email is not register in the same way.' - ); + throw new WrongSignInMethod(); } - await this.user.fulfillUser(externalAccount.email, { - emailVerifiedAt: new Date(), - registered: true, - }); await this.db.connectedAccount.create({ data: { userId: user.id, @@ -228,9 +228,7 @@ export class OAuthController { if (connectedUser) { if (connectedUser.id !== user.id) { - throw new BadRequestException( - 'The third-party account has already been connected to another user.' - ); + throw new OauthAccountAlreadyConnected(); } } else { await this.db.connectedAccount.create({ diff --git a/packages/backend/server/src/plugins/oauth/providers/github.ts b/packages/backend/server/src/plugins/oauth/providers/github.ts index 9b03c094b1bce..cfd72e0ea2662 100644 --- a/packages/backend/server/src/plugins/oauth/providers/github.ts +++ b/packages/backend/server/src/plugins/oauth/providers/github.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Config, URLHelper } from '../../../fundamentals'; import { OAuthProviderName } from '../config'; @@ -39,74 +39,60 @@ export class GithubOAuthProvider extends AutoRegisteredOAuthProvider { } async getToken(code: string) { - try { - const response = await fetch( - 'https://github.com/login/oauth/access_token', - { - method: 'POST', - body: this.url.stringify({ - code, - client_id: this.config.clientId, - client_secret: this.config.clientSecret, - redirect_uri: this.url.link('/oauth/callback'), - }), - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - } - ); + const response = await fetch( + 'https://github.com/login/oauth/access_token', + { + method: 'POST', + body: this.url.stringify({ + code, + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + redirect_uri: this.url.link('/oauth/callback'), + }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ); - if (response.ok) { - const ghToken = (await response.json()) as AuthTokenResponse; + if (response.ok) { + const ghToken = (await response.json()) as AuthTokenResponse; - return { - accessToken: ghToken.access_token, - scope: ghToken.scope, - }; - } else { - throw new Error( - `Server responded with non-success code ${ - response.status - }, ${JSON.stringify(await response.json())}` - ); - } - } catch (e) { - throw new HttpException( - `Failed to get access_token, err: ${(e as Error).message}`, - HttpStatus.BAD_REQUEST + return { + accessToken: ghToken.access_token, + scope: ghToken.scope, + }; + } else { + throw new Error( + `Server responded with non-success code ${ + response.status + }, ${JSON.stringify(await response.json())}` ); } } async getUser(token: string) { - try { - const response = await fetch('https://api.github.com/user', { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - }, - }); + const response = await fetch('https://api.github.com/user', { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }); - if (response.ok) { - const user = (await response.json()) as UserInfo; + if (response.ok) { + const user = (await response.json()) as UserInfo; - return { - id: user.login, - avatarUrl: user.avatar_url, - email: user.email, - }; - } else { - throw new Error( - `Server responded with non-success code ${ - response.status - } ${await response.text()}` - ); - } - } catch (e) { - throw new HttpException( - `Failed to get user information, err: ${(e as Error).stack}`, - HttpStatus.BAD_REQUEST + return { + id: user.login, + avatarUrl: user.avatar_url, + email: user.email, + }; + } else { + throw new Error( + `Server responded with non-success code ${ + response.status + } ${await response.text()}` ); } } diff --git a/packages/backend/server/src/plugins/oauth/providers/google.ts b/packages/backend/server/src/plugins/oauth/providers/google.ts index 8db41bf97b9e9..04c845c2f2d1d 100644 --- a/packages/backend/server/src/plugins/oauth/providers/google.ts +++ b/packages/backend/server/src/plugins/oauth/providers/google.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Config, URLHelper } from '../../../fundamentals'; import { OAuthProviderName } from '../config'; @@ -44,77 +44,63 @@ export class GoogleOAuthProvider extends AutoRegisteredOAuthProvider { } async getToken(code: string) { - try { - const response = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - body: this.url.stringify({ - code, - client_id: this.config.clientId, - client_secret: this.config.clientSecret, - redirect_uri: this.url.link('/oauth/callback'), - grant_type: 'authorization_code', - }), - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }); + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + body: this.url.stringify({ + code, + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + redirect_uri: this.url.link('/oauth/callback'), + grant_type: 'authorization_code', + }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); - if (response.ok) { - const ghToken = (await response.json()) as GoogleOAuthTokenResponse; + if (response.ok) { + const ghToken = (await response.json()) as GoogleOAuthTokenResponse; - return { - accessToken: ghToken.access_token, - refreshToken: ghToken.refresh_token, - expiresAt: new Date(Date.now() + ghToken.expires_in * 1000), - scope: ghToken.scope, - }; - } else { - throw new Error( - `Server responded with non-success code ${ - response.status - }, ${JSON.stringify(await response.json())}` - ); - } - } catch (e) { - throw new HttpException( - `Failed to get access_token, err: ${(e as Error).message}`, - HttpStatus.BAD_REQUEST + return { + accessToken: ghToken.access_token, + refreshToken: ghToken.refresh_token, + expiresAt: new Date(Date.now() + ghToken.expires_in * 1000), + scope: ghToken.scope, + }; + } else { + throw new Error( + `Server responded with non-success code ${ + response.status + }, ${JSON.stringify(await response.json())}` ); } } async getUser(token: string) { - try { - const response = await fetch( - 'https://www.googleapis.com/oauth2/v2/userinfo', - { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - }, - } - ); + const response = await fetch( + 'https://www.googleapis.com/oauth2/v2/userinfo', + { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); - if (response.ok) { - const user = (await response.json()) as UserInfo; + if (response.ok) { + const user = (await response.json()) as UserInfo; - return { - id: user.id, - avatarUrl: user.picture, - email: user.email, - }; - } else { - throw new Error( - `Server responded with non-success code ${ - response.status - } ${await response.text()}` - ); - } - } catch (e) { - throw new HttpException( - `Failed to get user information, err: ${(e as Error).stack}`, - HttpStatus.BAD_REQUEST + return { + id: user.id, + avatarUrl: user.picture, + email: user.email, + }; + } else { + throw new Error( + `Server responded with non-success code ${ + response.status + } ${await response.text()}` ); } } diff --git a/packages/backend/server/src/plugins/oauth/providers/oidc.ts b/packages/backend/server/src/plugins/oauth/providers/oidc.ts index 00cbb614135b8..e0d0e67f0f70f 100644 --- a/packages/backend/server/src/plugins/oauth/providers/oidc.ts +++ b/packages/backend/server/src/plugins/oauth/providers/oidc.ts @@ -1,9 +1,4 @@ -import { - BadRequestException, - Injectable, - InternalServerErrorException, - OnModuleInit, -} from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { z } from 'zod'; import { Config, URLHelper } from '../../../fundamentals'; @@ -44,6 +39,8 @@ const OIDCConfigurationSchema = z.object({ type OIDCConfiguration = z.infer; +const logger = new Logger('OIDCClient'); + class OIDCClient { private static async fetch( url: string, @@ -53,17 +50,8 @@ class OIDCClient { const response = await fetch(url, options); if (!response.ok) { - if (response.status >= 400 && response.status < 500) { - throw new BadRequestException(`Invalid OIDC configuration`, { - cause: await response.json(), - description: response.statusText, - }); - } else { - throw new InternalServerErrorException(`Failed to configure client`, { - cause: await response.json(), - description: response.statusText, - }); - } + logger.error('Failed to fetch OIDC configuration', await response.json()); + throw new Error(`Failed to configure client`); } const data = await response.json(); return verifier.parse(data); diff --git a/packages/backend/server/src/plugins/oauth/service.ts b/packages/backend/server/src/plugins/oauth/service.ts index faf95a683b946..d09993fa70395 100644 --- a/packages/backend/server/src/plugins/oauth/service.ts +++ b/packages/backend/server/src/plugins/oauth/service.ts @@ -20,6 +20,10 @@ export class OAuthService { private readonly cache: SessionCache ) {} + isValidState(stateStr: string) { + return stateStr.length === 36; + } + async saveOAuthState(state: OAuthState) { const token = randomUUID(); await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, state, { diff --git a/packages/backend/server/src/plugins/payment/resolver.ts b/packages/backend/server/src/plugins/payment/resolver.ts index f342074d2a9f5..3223642eff337 100644 --- a/packages/backend/server/src/plugins/payment/resolver.ts +++ b/packages/backend/server/src/plugins/payment/resolver.ts @@ -1,4 +1,3 @@ -import { BadGatewayException, ForbiddenException } from '@nestjs/common'; import { Args, Context, @@ -19,7 +18,12 @@ import { groupBy } from 'lodash-es'; import { CurrentUser, Public } from '../../core/auth'; import { UserType } from '../../core/user'; -import { Config, URLHelper } from '../../fundamentals'; +import { + AccessDenied, + Config, + FailedToCheckout, + URLHelper, +} from '../../fundamentals'; import { decodeLookupKey, SubscriptionService } from './service'; import { InvoiceStatus, @@ -227,7 +231,7 @@ export class SubscriptionResolver { }); if (!session.url) { - throw new BadGatewayException('Failed to create checkout session.'); + throw new FailedToCheckout(); } return session.url; @@ -322,9 +326,7 @@ export class UserSubscriptionResolver { ) { // allow admin to query other user's subscription if (!ctx.isAdminQuery && me.id !== user.id) { - throw new ForbiddenException( - 'You are not allowed to access this subscription.' - ); + throw new AccessDenied(); } // @FIXME(@forehalo): should not mock any api for selfhosted server @@ -363,9 +365,7 @@ export class UserSubscriptionResolver { @Parent() user: User ): Promise { if (me.id !== user.id) { - throw new ForbiddenException( - 'You are not allowed to access this subscription.' - ); + throw new AccessDenied(); } return this.db.userSubscription.findMany({ @@ -385,9 +385,7 @@ export class UserSubscriptionResolver { @Args('skip', { type: () => Int, nullable: true }) skip?: number ) { if (me.id !== user.id) { - throw new ForbiddenException( - 'You are not allowed to access this invoices' - ); + throw new AccessDenied(); } return this.db.userInvoice.findMany({ diff --git a/packages/backend/server/src/plugins/payment/service.ts b/packages/backend/server/src/plugins/payment/service.ts index 3bf2a46391047..0320f3202ca82 100644 --- a/packages/backend/server/src/plugins/payment/service.ts +++ b/packages/backend/server/src/plugins/payment/service.ts @@ -1,6 +1,6 @@ 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 type { Prisma, @@ -14,7 +14,20 @@ import Stripe from 'stripe'; import { CurrentUser } from '../../core/auth'; 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 { InvoiceStatus, @@ -160,7 +173,7 @@ export class SubscriptionService { this.config.affine.canary && !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({ @@ -172,9 +185,7 @@ export class SubscriptionService { }); if (currentSubscription) { - throw new BadRequestException( - `You've already subscribed to the ${plan} plan` - ); + throw new SubscriptionAlreadyExists({ plan }); } const customer = await this.getOrCreateCustomer( @@ -245,18 +256,16 @@ export class SubscriptionService { }); if (!user) { - throw new BadRequestException('Unknown user'); + throw new UserNotFound(); } const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan); if (!subscriptionInDB) { - throw new BadRequestException(`You didn't subscribe to the ${plan} plan`); + throw new SubscriptionNotExists({ plan }); } if (subscriptionInDB.canceledAt) { - throw new BadRequestException( - 'Your subscription has already been canceled' - ); + throw new SubscriptionHasBeenCanceled(); } // should release the schedule first @@ -298,22 +307,20 @@ export class SubscriptionService { }); if (!user) { - throw new BadRequestException('Unknown user'); + throw new UserNotFound(); } const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan); if (!subscriptionInDB) { - throw new BadRequestException(`You didn't subscribe to the ${plan} plan`); + throw new SubscriptionNotExists({ plan }); } if (!subscriptionInDB.canceledAt) { - throw new BadRequestException('Your subscription has not been canceled'); + throw new SubscriptionHasBeenCanceled(); } if (subscriptionInDB.end < new Date()) { - throw new BadRequestException( - 'Your subscription is expired, please checkout again.' - ); + throw new SubscriptionExpired(); } if (subscriptionInDB.stripeScheduleId) { @@ -354,23 +361,19 @@ export class SubscriptionService { }); if (!user) { - throw new BadRequestException('Unknown user'); + throw new UserNotFound(); } const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan); if (!subscriptionInDB) { - throw new BadRequestException(`You didn't subscribe to the ${plan} plan`); + throw new SubscriptionNotExists({ plan }); } if (subscriptionInDB.canceledAt) { - throw new BadRequestException( - 'Your subscription has already been canceled' - ); + throw new SubscriptionHasBeenCanceled(); } if (subscriptionInDB.recurring === recurring) { - throw new BadRequestException( - `You are already in ${recurring} recurring` - ); + throw new SameSubscriptionRecurring({ recurring }); } const price = await this.getPrice( @@ -404,7 +407,7 @@ export class SubscriptionService { }); if (!user) { - throw new BadRequestException('Unknown user'); + throw new UserNotFound(); } try { @@ -415,7 +418,7 @@ export class SubscriptionService { return portal.url; } catch (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) { - throw new BadRequestException( - `Unknown subscription plan ${plan} with ${recurring} recurring` - ); + throw new SubscriptionPlanNotFound({ + plan, + recurring, + }); } return prices.data[0].id; diff --git a/packages/backend/server/src/plugins/payment/webhook.ts b/packages/backend/server/src/plugins/payment/webhook.ts index 0916e000c296d..f2dcf4c396bea 100644 --- a/packages/backend/server/src/plugins/payment/webhook.ts +++ b/packages/backend/server/src/plugins/payment/webhook.ts @@ -1,19 +1,13 @@ import assert from 'node:assert'; import type { RawBodyRequest } from '@nestjs/common'; -import { - Controller, - Logger, - NotAcceptableException, - Post, - Req, -} from '@nestjs/common'; +import { Controller, Logger, Post, Req } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import type { Request } from 'express'; import Stripe from 'stripe'; import { Public } from '../../core/auth'; -import { Config } from '../../fundamentals'; +import { Config, InternalServerError } from '../../fundamentals'; @Controller('/api/stripe') export class StripeWebhook { @@ -55,9 +49,8 @@ export class StripeWebhook { this.logger.error('Failed to handle Stripe Webhook event.', e); }); }); - } catch (err) { - this.logger.error('Stripe Webhook error', err); - throw new NotAcceptableException(); + } catch (err: any) { + throw new InternalServerError(err.message); } } } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 9816f72cea646..812d75e49c6b8 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -2,6 +2,11 @@ # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) # ------------------------------------------------------ +type BlobNotFoundDataType { + blobId: String! + workspaceId: String! +} + type ChatMessage { attachments: [String!] content: String! @@ -65,6 +70,10 @@ type CopilotPromptMessageType { role: CopilotPromptMessageRole! } +type CopilotPromptNotFoundDataType { + name: String! +} + type CopilotPromptType { action: String messages: [CopilotPromptMessageType!]! @@ -133,17 +142,98 @@ input DeleteSessionInput { workspaceId: String! } +type DocAccessDeniedDataType { + docId: String! + workspaceId: String! +} + +type DocHistoryNotFoundDataType { + docId: String! + timestamp: Int! + workspaceId: String! +} + type DocHistoryType { id: String! timestamp: DateTime! workspaceId: String! } +type DocNotFoundDataType { + docId: String! + workspaceId: String! +} + enum EarlyAccessType { AI 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""" enum FeatureType { AIEarlyAccess @@ -163,6 +253,21 @@ type HumanReadableQuotaType { storageQuota: String! } +type InvalidHistoryTimestampDataType { + timestamp: String! +} + +type InvalidPasswordLengthDataType { + max: Int! + min: Int! +} + +type InvalidRuntimeConfigTypeDataType { + get: String! + key: String! + want: String! +} + type InvitationType { """Invitee information""" invitee: UserType! @@ -244,6 +349,10 @@ input ListUserInput { skip: Int = 0 } +type MissingOauthQueryParameterDataType { + name: String! +} + type Mutation { acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean! addAdminister(email: String!): Boolean! @@ -323,6 +432,10 @@ type Mutation { verifyEmail(token: String!): Boolean! } +type NotInWorkspaceDataType { + workspaceId: String! +} + enum OAuthProviderType { GitHub Google @@ -355,6 +468,7 @@ type Query { """Get current user""" currentUser: UserType earlyAccessUsers: [UserType!]! + error(name: ErrorNames!): ErrorDataUnion! """send workspace invitation""" getInviteInfo(inviteId: String!): InvitationType! @@ -414,6 +528,10 @@ type RemoveAvatar { success: Boolean! } +type RuntimeConfigNotFoundDataType { + key: String! +} + enum RuntimeConfigType { Array 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") +type SameSubscriptionRecurringDataType { + recurring: String! +} + type ServerConfigType { """server base url""" baseUrl: String! @@ -483,6 +605,14 @@ type ServerRuntimeConfigType { value: JSON! } +type SubscriptionAlreadyExistsDataType { + plan: String! +} + +type SubscriptionNotExistsDataType { + plan: String! +} + enum SubscriptionPlan { AI Enterprise @@ -492,6 +622,11 @@ enum SubscriptionPlan { Team } +type SubscriptionPlanNotFoundDataType { + plan: String! + recurring: String! +} + type SubscriptionPrice { amount: Int currency: String! @@ -516,6 +651,10 @@ enum SubscriptionStatus { Unpaid } +type UnknownOauthProviderDataType { + name: String! +} + input UpdateUserInput { """User name""" name: String @@ -617,10 +756,27 @@ type UserType { token: tokenType! @deprecated(reason: "use [/api/auth/authorize]") } +type VersionRejectedDataType { + serverVersion: String! + version: String! +} + +type WorkspaceAccessDeniedDataType { + workspaceId: String! +} + type WorkspaceBlobSizes { size: SafeInt! } +type WorkspaceNotFoundDataType { + workspaceId: String! +} + +type WorkspaceOwnerNotFoundDataType { + workspaceId: String! +} + type WorkspacePage { id: String! mode: PublicPageMode! diff --git a/packages/backend/server/tests/auth/controller.spec.ts b/packages/backend/server/tests/auth/controller.spec.ts index 6c498c15bdeef..baf6e0339db0f 100644 --- a/packages/backend/server/tests/auth/controller.spec.ts +++ b/packages/backend/server/tests/auth/controller.spec.ts @@ -119,7 +119,7 @@ test('should not be able to sign in if email is invalid', async t => { .send({ email: '' }) .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 => { @@ -130,7 +130,7 @@ test('should not be able to sign in if forbidden', async t => { await request(app.getHttpServer()) .post('/api/auth/sign-in') .send({ email: u1.email }) - .expect(HttpStatus.BAD_REQUEST); + .expect(HttpStatus.FORBIDDEN); t.true(mailer.sendSignInMail.notCalled); diff --git a/packages/backend/server/tests/auth/guard.spec.ts b/packages/backend/server/tests/auth/guard.spec.ts index b5b7a73187d19..3acfac464945e 100644 --- a/packages/backend/server/tests/auth/guard.spec.ts +++ b/packages/backend/server/tests/auth/guard.spec.ts @@ -86,9 +86,11 @@ test('should not be able to visit private api if not signed in', async t => { .get('/private') .expect(HttpStatus.UNAUTHORIZED) .expect({ - statusCode: 401, - message: 'You are not signed in.', - error: 'Unauthorized', + status: 401, + code: 'Unauthorized', + type: 'AUTHENTICATION_REQUIRED', + name: 'AUTHENTICATION_REQUIRED', + message: 'You must sign in first to access this resource.', }); t.assert(true); diff --git a/packages/backend/server/tests/auth/service.spec.ts b/packages/backend/server/tests/auth/service.spec.ts index 017ce4e303205..de6ee350da2a5 100644 --- a/packages/backend/server/tests/auth/service.spec.ts +++ b/packages/backend/server/tests/auth/service.spec.ts @@ -66,7 +66,7 @@ test('should throw if email duplicated', async t => { const { auth } = t.context; 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; 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'), { - 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; 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( () => 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 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'); diff --git a/packages/backend/server/tests/graphql.spec.ts b/packages/backend/server/tests/graphql.spec.ts deleted file mode 100644 index 815da59b95ded..0000000000000 --- a/packages/backend/server/tests/graphql.spec.ts +++ /dev/null @@ -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]); -}); diff --git a/packages/backend/server/tests/nestjs/error-handler.spec.ts b/packages/backend/server/tests/nestjs/error-handler.spec.ts new file mode 100644 index 0000000000000..b5a36fac55c7b --- /dev/null +++ b/packages/backend/server/tests/nestjs/error-handler.spec.ts @@ -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; +}>; + +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')); +}); diff --git a/packages/backend/server/tests/oauth/controller.spec.ts b/packages/backend/server/tests/oauth/controller.spec.ts index 7eaa52eed8b41..dff50c2c9c191 100644 --- a/packages/backend/server/tests/oauth/controller.spec.ts +++ b/packages/backend/server/tests/oauth/controller.spec.ts @@ -86,12 +86,15 @@ test('should throw if provider is invalid', async t => { .get('/oauth/login?provider=Invalid') .expect(HttpStatus.BAD_REQUEST) .expect({ - statusCode: 400, - message: 'Invalid OAuth provider', - error: 'Bad Request', + status: 400, + code: '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 => { @@ -124,12 +127,15 @@ test('should throw if code is missing in callback uri', async t => { .get('/oauth/callback') .expect(HttpStatus.BAD_REQUEST) .expect({ - statusCode: 400, - message: 'Missing query parameter `code`', - error: 'Bad Request', + status: 400, + code: '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 => { @@ -139,27 +145,50 @@ test('should throw if state is missing in callback uri', async t => { .get('/oauth/callback?code=1') .expect(HttpStatus.BAD_REQUEST) .expect({ - statusCode: 400, - message: 'Invalid callback state parameter', - error: 'Bad Request', + status: 400, + code: '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 => { - const { app } = t.context; + 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({ - statusCode: 400, + status: 400, + code: 'Bad Request', + type: 'BAD_REQUEST', + name: 'OAUTH_STATE_EXPIRED', message: 'OAuth state expired, please try again.', - error: 'Bad Request', }); - t.assert(true); + t.pass(); +}); + +test('should throw if state is invalid', async t => { + const { app } = t.context; + + 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: 'INVALID_OAUTH_CALLBACK_STATE', + message: 'Invalid callback state parameter.', + }); + + t.pass(); }); 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 Sinon.stub(oauth, 'getOAuthState').resolves({}); + Sinon.stub(oauth, 'isValidState').resolves(true); await request(app.getHttpServer()) .get(`/oauth/callback?code=1&state=1`) .expect(HttpStatus.BAD_REQUEST) .expect({ - statusCode: 400, - message: 'Missing callback state parameter `provider`', - error: 'Bad Request', + status: 400, + code: '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 => { @@ -185,23 +218,28 @@ test('should throw if provider is invalid in callback uri', async t => { // @ts-expect-error mock Sinon.stub(oauth, 'getOAuthState').resolves({ provider: 'Invalid' }); + Sinon.stub(oauth, 'isValidState').resolves(true); await request(app.getHttpServer()) .get(`/oauth/callback?code=1&state=1`) .expect(HttpStatus.BAD_REQUEST) .expect({ - statusCode: 400, - message: 'Invalid provider', - error: 'Bad Request', + status: 400, + code: '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) { const provider = app.get(GoogleOAuthProvider); const oauth = app.get(OAuthService); + Sinon.stub(oauth, 'isValidState').resolves(true); Sinon.stub(oauth, 'getOAuthState').resolves({ provider: OAuthProviderName.Google, redirectUri: '/', @@ -259,7 +297,7 @@ test('should throw if account register in another way', async t => { t.is(link.pathname, '/signIn'); t.is( 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.' ); }); diff --git a/packages/backend/server/tests/payment/service.spec.ts b/packages/backend/server/tests/payment/service.spec.ts index cd64ccf0b82d8..a34832a05ff64 100644 --- a/packages/backend/server/tests/payment/service.spec.ts +++ b/packages/backend/server/tests/payment/service.spec.ts @@ -356,7 +356,7 @@ test('should throw if user has subscription already', async t => { redirectUrl: '', idempotencyKey: '', }), - { message: "You've already subscribed to the pro plan" } + { message: 'You have already subscribed to the pro plan.' } ); }); diff --git a/packages/backend/server/tests/utils/utils.ts b/packages/backend/server/tests/utils/utils.ts index 567555bffd18f..1ecc7993cb53f 100644 --- a/packages/backend/server/tests/utils/utils.ts +++ b/packages/backend/server/tests/utils/utils.ts @@ -10,6 +10,7 @@ import type { Response } from 'supertest'; import { AppModule, FunctionalityModules } from '../../src/app.module'; import { AuthGuard, AuthModule } from '../../src/core/auth'; import { UserFeaturesInit1698652531198 } from '../../src/data/migrations/1698652531198-user-features-init'; +import { GlobalExceptionFilter } from '../../src/fundamentals'; import { GqlModule } from '../../src/fundamentals/graphql'; async function flushDB(client: PrismaClient) { @@ -116,6 +117,7 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) { logger: ['warn'], }); + app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter())); app.use( graphqlUploadExpress({ maxFileSize: 10 * 1024 * 1024, diff --git a/packages/backend/server/tests/workspace.e2e.ts b/packages/backend/server/tests/workspace.e2e.ts index 4671b80b52303..997d542b7d348 100644 --- a/packages/backend/server/tests/workspace.e2e.ts +++ b/packages/backend/server/tests/workspace.e2e.ts @@ -9,7 +9,6 @@ import { acceptInviteById, createTestingApp, createWorkspace, - currentUser, getWorkspacePublicPages, inviteUser, publishPage, @@ -43,19 +42,6 @@ test('should register a user', async t => { 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 => { const { app } = t.context; 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'); 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( app, u2.token.token, 'not_exists_ws', '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( app, diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/copilot-client.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/copilot-client.ts index 7add52fbeb0f7..095ed82d74710 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/copilot-client.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/copilot-client.ts @@ -11,6 +11,7 @@ import { type GraphQLQuery, type QueryOptions, type RequestOptions, + UserFriendlyError, } from '@affine/graphql'; import { GeneralNetworkError, @@ -33,27 +34,16 @@ function codeToError(code: number) { } } -type ErrorType = - | GraphQLError[] - | GraphQLError - | { status: number } - | Error - | string; - -export function resolveError(src: ErrorType) { - 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 resolveError(err: any) { + const standardError = + err instanceof GraphQLError + ? new UserFriendlyError(err.extensions) + : UserFriendlyError.fromAnyError(err); + + return codeToError(standardError.status); } -export function handleError(src: ErrorType) { +export function handleError(src: any) { const err = resolveError(src); if (err instanceof UnauthorizedError) { getCurrentStore().set(showAILoginRequiredAtom, true); @@ -66,8 +56,7 @@ const fetcher = async ( ) => { try { return await defaultFetcher(options); - } catch (_err) { - const err = _err as GraphQLError | GraphQLError[] | Error | string; + } catch (err) { throw handleError(err); } }; diff --git a/packages/frontend/core/src/modules/cloud/services/fetch.ts b/packages/frontend/core/src/modules/cloud/services/fetch.ts index 2ae70358ad821..988bf9726038a 100644 --- a/packages/frontend/core/src/modules/cloud/services/fetch.ts +++ b/packages/frontend/core/src/modules/cloud/services/fetch.ts @@ -1,4 +1,5 @@ import { DebugLogger } from '@affine/debug'; +import { UserFriendlyError } from '@affine/graphql'; import { fromPromise, Service } from '@toeverything/infra'; import { BackendError, NetworkError } from '../error'; @@ -75,9 +76,7 @@ export class FetchService extends Service { // ignore } } - throw new BackendError( - new Error(`${res.status} ${res.statusText}`, reason) - ); + throw new BackendError(UserFriendlyError.fromAnyError(reason)); } return res; }; diff --git a/packages/frontend/core/src/modules/cloud/services/graphql.ts b/packages/frontend/core/src/modules/cloud/services/graphql.ts index e2789a7746452..4ac2faf3b8914 100644 --- a/packages/frontend/core/src/modules/cloud/services/graphql.ts +++ b/packages/frontend/core/src/modules/cloud/services/graphql.ts @@ -1,9 +1,9 @@ import { gqlFetcherFactory, - GraphQLError, type GraphQLQuery, type QueryOptions, type QueryResponse, + UserFriendlyError, } from '@affine/graphql'; import { fromPromise, Service } from '@toeverything/infra'; import type { Observable } from 'rxjs'; @@ -39,15 +39,13 @@ export class GraphQLService extends Service { try { return await this.rawGql(options); } catch (err) { - if (err instanceof Array) { - for (const error of err) { - if (error instanceof GraphQLError && error.extensions?.code === 403) { - this.framework.get(AuthService).session.revalidate(); - } - } - throw new BackendError(new Error('Graphql Error')); + const standardError = UserFriendlyError.fromAnyError(err); + + if (standardError.status === 403) { + this.framework.get(AuthService).session.revalidate(); } - throw err; + + throw new BackendError(standardError); } }; } diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-cloud.ts b/packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-cloud.ts index 65013d67085f9..3a41218bb2b53 100644 --- a/packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-cloud.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-cloud.ts @@ -1,10 +1,10 @@ import { deleteBlobMutation, fetcher, - findGraphQLError, getBaseUrl, listBlobsQuery, setBlobMutation, + UserFriendlyError, } from '@affine/graphql'; import type { BlobStorage } from '@toeverything/infra'; import { BlobStorageOverCapacity } from '@toeverything/infra'; @@ -44,13 +44,9 @@ export class CloudBlobStorage implements BlobStorage { }) .then(res => res.setBlob) .catch(err => { - const uploadError = findGraphQLError( - err, - e => e.extensions.code === 413 - ); - - if (uploadError) { - throw new BlobStorageOverCapacity(uploadError); + const error = UserFriendlyError.fromAnyError(err); + if (error.status === 413) { + throw new BlobStorageOverCapacity(error); } throw err; diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-cloud.ts b/packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-cloud.ts index 4f2009e6103a3..482e1dbf180dd 100644 --- a/packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-cloud.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-cloud.ts @@ -1,4 +1,9 @@ import { DebugLogger } from '@affine/debug'; +import { + ErrorNames, + UserFriendlyError, + type UserFriendlyErrorResponse, +} from '@affine/graphql'; import type { DocServer } from '@toeverything/infra'; import { throwIfAborted } from '@toeverything/infra'; 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'); +type WebsocketResponse = { error: UserFriendlyErrorResponse } | { data: T }; + export class CloudDocEngineServer implements DocServer { interruptCb: ((reason: string) => void) | null = null; SEND_TIMEOUT = 30000; @@ -31,21 +38,24 @@ export class CloudDocEngineServer implements DocServer { const stateVector = state ? await uint8ArrayToBase64(state) : undefined; - const response: - | { error: any } - | { data: { missing: string; state: string; timestamp: number } } = - await this.socket.timeout(this.SEND_TIMEOUT).emitWithAck('doc-load-v2', { + const response: WebsocketResponse<{ + missing: string; + state: string; + timestamp: number; + }> = await this.socket + .timeout(this.SEND_TIMEOUT) + .emitWithAck('doc-load-v2', { workspaceId: this.workspaceId, guid: docId, stateVector, }); if ('error' in response) { - // TODO: result `EventError` with server - if (response.error.code === 'DOC_NOT_FOUND') { + const error = new UserFriendlyError(response.error); + if (error.name === ErrorNames.DOC_NOT_FOUND) { return null; } else { - throw new Error(response.error.message); + throw error; } } else { return { @@ -60,11 +70,7 @@ export class CloudDocEngineServer implements DocServer { async pushDoc(docId: string, data: Uint8Array) { const payload = await uint8ArrayToBase64(data); - const response: { - // TODO: reuse `EventError` with server - error?: any; - data: { timestamp: number }; - } = await this.socket + const response: WebsocketResponse<{ timestamp: number }> = await this.socket .timeout(this.SEND_TIMEOUT) .emitWithAck('client-update-v2', { workspaceId: this.workspaceId, @@ -72,38 +78,34 @@ export class CloudDocEngineServer implements DocServer { updates: [payload], }); - // TODO: raise error with different code to users - if (response.error) { + if ('error' in response) { logger.error('client-update-v2 error', { workspaceId: this.workspaceId, guid: docId, response, }); - throw new Error(response.error); + throw new UserFriendlyError(response.error); } return { serverClock: response.data.timestamp }; } async loadServerClock(after: number): Promise> { - const response: { - // TODO: reuse `EventError` with server - error?: any; - data: Record; - } = await this.socket - .timeout(this.SEND_TIMEOUT) - .emitWithAck('client-pre-sync', { - workspaceId: this.workspaceId, - timestamp: after, - }); + const response: WebsocketResponse> = + await this.socket + .timeout(this.SEND_TIMEOUT) + .emitWithAck('client-pre-sync', { + workspaceId: this.workspaceId, + timestamp: after, + }); - if (response.error) { + if ('error' in response) { logger.error('client-pre-sync error', { workspaceId: this.workspaceId, response, }); - throw new Error(response.error); + throw new UserFriendlyError(response.error); } return new Map(Object.entries(response.data)); diff --git a/packages/frontend/graphql/codegen.yml b/packages/frontend/graphql/codegen.yml index 6870b8f23f8be..220382380c36b 100644 --- a/packages/frontend/graphql/codegen.yml +++ b/packages/frontend/graphql/codegen.yml @@ -7,7 +7,6 @@ config: declarationKind: interface avoidOptionals: true preResolveTypes: true - onlyOperationTypes: true namingConvention: enumValues: keep scalars: diff --git a/packages/frontend/graphql/src/__tests__/fetcher.spec.ts b/packages/frontend/graphql/src/__tests__/fetcher.spec.ts index 722e0f5eb83d9..c7b9baea8e7ef 100644 --- a/packages/frontend/graphql/src/__tests__/fetcher.spec.ts +++ b/packages/frontend/graphql/src/__tests__/fetcher.spec.ts @@ -102,11 +102,8 @@ describe('GraphQL fetcher', () => { ) ); - await expect(gql({ query, variables: void 0 })).rejects - .toMatchInlineSnapshot(` - [ - [GraphQLError: error], - ] - `); + await expect( + gql({ query, variables: void 0 }) + ).rejects.toMatchInlineSnapshot(`[GraphQLError: error]`); }); }); diff --git a/packages/frontend/graphql/src/error.ts b/packages/frontend/graphql/src/error.ts index 02aaa28b17cef..aee5571e6795b 100644 --- a/packages/frontend/graphql/src/error.ts +++ b/packages/frontend/graphql/src/error.ts @@ -1,26 +1,59 @@ import { GraphQLError as BaseGraphQLError } from 'graphql'; -import { identity } from 'lodash-es'; -interface KnownGraphQLErrorExtensions { - code: number; - status: string; - originalError?: unknown; +import { type ErrorDataUnion, ErrorNames } from './schema'; + +export interface UserFriendlyErrorResponse { + status: number; + code: string; + type: string; + name: ErrorNames; + message: string; + args?: any; 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 { // @ts-expect-error better to be a known type without any type casting - override extensions!: KnownGraphQLErrorExtensions; -} -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; - } + override extensions!: UserFriendlyErrorResponse; } + +type ToPascalCase = S extends `${infer A}_${infer B}` + ? `${Capitalize>}${ToPascalCase}` + : Capitalize>; + +export type ErrorData = { + [K in ErrorNames]: Extract< + ErrorDataUnion, + { __typename?: `${ToPascalCase}DataType` } + >; +}; diff --git a/packages/frontend/graphql/src/fetcher.ts b/packages/frontend/graphql/src/fetcher.ts index dade9cb0cf975..a353e17c0a2ea 100644 --- a/packages/frontend/graphql/src/fetcher.ts +++ b/packages/frontend/graphql/src/fetcher.ts @@ -195,9 +195,9 @@ export const gqlFetcherFactory = ( const result = (await res.json()) as ExecutionResult; if (res.status >= 400 || result.errors) { if (result.errors && result.errors.length > 0) { - throw result.errors.map( - error => new GraphQLError(error.message, error) - ); + // throw the first error is enough + const firstError = result.errors[0]; + throw new GraphQLError(firstError.message, firstError); } else { throw new GraphQLError('Empty GraphQL error body'); } diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index f79b2ebf31c2c..d38760709d89f 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -38,6 +38,49 @@ export interface Scalars { Upload: { input: File; output: File }; } +export interface BlobNotFoundDataType { + __typename?: 'BlobNotFoundDataType'; + blobId: Scalars['String']['output']; + workspaceId: Scalars['String']['output']; +} + +export interface ChatMessage { + __typename?: 'ChatMessage'; + attachments: Maybe>; + content: Scalars['String']['output']; + createdAt: Scalars['DateTime']['output']; + params: Maybe; + role: Scalars['String']['output']; +} + +export interface Copilot { + __typename?: 'Copilot'; + /** Get the session list of actions in the workspace */ + actions: Array; + /** Get the session list of chats in the workspace */ + chats: Array; + histories: Array; + /** Get the quota of the user in the workspace */ + quota: CopilotQuota; + workspaceId: Maybe; +} + +export interface CopilotHistoriesArgs { + docId: InputMaybe; + options: InputMaybe; +} + +export interface CopilotHistories { + __typename?: 'CopilotHistories'; + /** An mark identifying which view to use to display the session */ + action: Maybe; + createdAt: Scalars['DateTime']['output']; + messages: Array; + sessionId: Scalars['String']['output']; + /** The number of tokens used in the session */ + tokens: Scalars['Int']['output']; +} + export enum CopilotModels { DallE3 = 'DallE3', Gpt4Omni = 'Gpt4Omni', @@ -63,6 +106,32 @@ export enum CopilotPromptMessageRole { user = 'user', } +export interface CopilotPromptMessageType { + __typename?: 'CopilotPromptMessageType'; + content: Scalars['String']['output']; + params: Maybe; + role: CopilotPromptMessageRole; +} + +export interface CopilotPromptNotFoundDataType { + __typename?: 'CopilotPromptNotFoundDataType'; + name: Scalars['String']['output']; +} + +export interface CopilotPromptType { + __typename?: 'CopilotPromptType'; + action: Maybe; + messages: Array; + model: CopilotModels; + name: Scalars['String']['output']; +} + +export interface CopilotQuota { + __typename?: 'CopilotQuota'; + limit: Maybe; + used: Scalars['SafeInt']['output']; +} + export interface CreateChatMessageInput { attachments: InputMaybe>; blobs: InputMaybe>; @@ -99,17 +168,137 @@ export interface CreateUserInput { password: InputMaybe; } +export interface CredentialsRequirementType { + __typename?: 'CredentialsRequirementType'; + password: PasswordLimitsType; +} + +export interface DeleteAccount { + __typename?: 'DeleteAccount'; + success: Scalars['Boolean']['output']; +} + export interface DeleteSessionInput { docId: Scalars['String']['input']; sessionIds: Array; workspaceId: Scalars['String']['input']; } +export interface DocAccessDeniedDataType { + __typename?: 'DocAccessDeniedDataType'; + docId: Scalars['String']['output']; + workspaceId: Scalars['String']['output']; +} + +export interface DocHistoryNotFoundDataType { + __typename?: 'DocHistoryNotFoundDataType'; + docId: Scalars['String']['output']; + timestamp: Scalars['Int']['output']; + workspaceId: Scalars['String']['output']; +} + +export interface DocHistoryType { + __typename?: 'DocHistoryType'; + id: Scalars['String']['output']; + timestamp: Scalars['DateTime']['output']; + workspaceId: Scalars['String']['output']; +} + +export interface DocNotFoundDataType { + __typename?: 'DocNotFoundDataType'; + docId: Scalars['String']['output']; + workspaceId: Scalars['String']['output']; +} + export enum EarlyAccessType { AI = 'AI', App = 'App', } +export type ErrorDataUnion = + | BlobNotFoundDataType + | CopilotPromptNotFoundDataType + | DocAccessDeniedDataType + | DocHistoryNotFoundDataType + | DocNotFoundDataType + | InvalidHistoryTimestampDataType + | InvalidPasswordLengthDataType + | InvalidRuntimeConfigTypeDataType + | MissingOauthQueryParameterDataType + | NotInWorkspaceDataType + | RuntimeConfigNotFoundDataType + | SameSubscriptionRecurringDataType + | SubscriptionAlreadyExistsDataType + | SubscriptionNotExistsDataType + | SubscriptionPlanNotFoundDataType + | UnknownOauthProviderDataType + | VersionRejectedDataType + | WorkspaceAccessDeniedDataType + | WorkspaceNotFoundDataType + | WorkspaceOwnerNotFoundDataType; + +export enum ErrorNames { + ACCESS_DENIED = 'ACCESS_DENIED', + ACTION_FORBIDDEN = 'ACTION_FORBIDDEN', + AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED', + BLOB_NOT_FOUND = 'BLOB_NOT_FOUND', + BLOB_QUOTA_EXCEEDED = 'BLOB_QUOTA_EXCEEDED', + CANT_CHANGE_WORKSPACE_OWNER = 'CANT_CHANGE_WORKSPACE_OWNER', + COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN', + COPILOT_FAILED_TO_CREATE_MESSAGE = 'COPILOT_FAILED_TO_CREATE_MESSAGE', + COPILOT_FAILED_TO_GENERATE_TEXT = 'COPILOT_FAILED_TO_GENERATE_TEXT', + COPILOT_MESSAGE_NOT_FOUND = 'COPILOT_MESSAGE_NOT_FOUND', + COPILOT_PROMPT_NOT_FOUND = 'COPILOT_PROMPT_NOT_FOUND', + COPILOT_QUOTA_EXCEEDED = 'COPILOT_QUOTA_EXCEEDED', + COPILOT_SESSION_DELETED = 'COPILOT_SESSION_DELETED', + COPILOT_SESSION_NOT_FOUND = 'COPILOT_SESSION_NOT_FOUND', + CUSTOMER_PORTAL_CREATE_FAILED = 'CUSTOMER_PORTAL_CREATE_FAILED', + DOC_ACCESS_DENIED = 'DOC_ACCESS_DENIED', + DOC_HISTORY_NOT_FOUND = 'DOC_HISTORY_NOT_FOUND', + DOC_NOT_FOUND = 'DOC_NOT_FOUND', + EARLY_ACCESS_REQUIRED = 'EARLY_ACCESS_REQUIRED', + EMAIL_ALREADY_USED = 'EMAIL_ALREADY_USED', + EMAIL_TOKEN_NOT_FOUND = 'EMAIL_TOKEN_NOT_FOUND', + EMAIL_VERIFICATION_REQUIRED = 'EMAIL_VERIFICATION_REQUIRED', + EXPECT_TO_PUBLISH_PAGE = 'EXPECT_TO_PUBLISH_PAGE', + EXPECT_TO_REVOKE_PUBLIC_PAGE = 'EXPECT_TO_REVOKE_PUBLIC_PAGE', + FAILED_TO_CHECKOUT = 'FAILED_TO_CHECKOUT', + INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', + INVALID_EMAIL = 'INVALID_EMAIL', + INVALID_EMAIL_TOKEN = 'INVALID_EMAIL_TOKEN', + INVALID_HISTORY_TIMESTAMP = 'INVALID_HISTORY_TIMESTAMP', + INVALID_OAUTH_CALLBACK_STATE = 'INVALID_OAUTH_CALLBACK_STATE', + INVALID_PASSWORD_LENGTH = 'INVALID_PASSWORD_LENGTH', + INVALID_RUNTIME_CONFIG_TYPE = 'INVALID_RUNTIME_CONFIG_TYPE', + MAILER_SERVICE_IS_NOT_CONFIGURED = 'MAILER_SERVICE_IS_NOT_CONFIGURED', + MEMBER_QUOTA_EXCEEDED = 'MEMBER_QUOTA_EXCEEDED', + MISSING_OAUTH_QUERY_PARAMETER = 'MISSING_OAUTH_QUERY_PARAMETER', + NOT_IN_WORKSPACE = 'NOT_IN_WORKSPACE', + NO_COPILOT_PROVIDER_AVAILABLE = 'NO_COPILOT_PROVIDER_AVAILABLE', + OAUTH_STATE_EXPIRED = 'OAUTH_STATE_EXPIRED', + PAGE_IS_NOT_PUBLIC = 'PAGE_IS_NOT_PUBLIC', + RUNTIME_CONFIG_NOT_FOUND = 'RUNTIME_CONFIG_NOT_FOUND', + SAME_EMAIL_PROVIDED = 'SAME_EMAIL_PROVIDED', + SAME_SUBSCRIPTION_RECURRING = 'SAME_SUBSCRIPTION_RECURRING', + SIGN_UP_FORBIDDEN = 'SIGN_UP_FORBIDDEN', + SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS', + SUBSCRIPTION_EXPIRED = 'SUBSCRIPTION_EXPIRED', + SUBSCRIPTION_HAS_BEEN_CANCELED = 'SUBSCRIPTION_HAS_BEEN_CANCELED', + SUBSCRIPTION_NOT_EXISTS = 'SUBSCRIPTION_NOT_EXISTS', + SUBSCRIPTION_PLAN_NOT_FOUND = 'SUBSCRIPTION_PLAN_NOT_FOUND', + TOO_MANY_REQUEST = 'TOO_MANY_REQUEST', + UNKNOWN_OAUTH_PROVIDER = 'UNKNOWN_OAUTH_PROVIDER', + UNSPLASH_IS_NOT_CONFIGURED = 'UNSPLASH_IS_NOT_CONFIGURED', + USER_AVATAR_NOT_FOUND = 'USER_AVATAR_NOT_FOUND', + USER_NOT_FOUND = 'USER_NOT_FOUND', + VERSION_REJECTED = 'VERSION_REJECTED', + WORKSPACE_ACCESS_DENIED = 'WORKSPACE_ACCESS_DENIED', + WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', + WORKSPACE_OWNER_NOT_FOUND = 'WORKSPACE_OWNER_NOT_FOUND', + WRONG_SIGN_IN_CREDENTIALS = 'WRONG_SIGN_IN_CREDENTIALS', + WRONG_SIGN_IN_METHOD = 'WRONG_SIGN_IN_METHOD', +} + /** The type of workspace feature */ export enum FeatureType { AIEarlyAccess = 'AIEarlyAccess', @@ -120,6 +309,79 @@ export enum FeatureType { UnlimitedWorkspace = 'UnlimitedWorkspace', } +export interface HumanReadableQuotaType { + __typename?: 'HumanReadableQuotaType'; + blobLimit: Scalars['String']['output']; + copilotActionLimit: Maybe; + historyPeriod: Scalars['String']['output']; + memberLimit: Scalars['String']['output']; + name: Scalars['String']['output']; + storageQuota: Scalars['String']['output']; +} + +export interface InvalidHistoryTimestampDataType { + __typename?: 'InvalidHistoryTimestampDataType'; + timestamp: Scalars['String']['output']; +} + +export interface InvalidPasswordLengthDataType { + __typename?: 'InvalidPasswordLengthDataType'; + max: Scalars['Int']['output']; + min: Scalars['Int']['output']; +} + +export interface InvalidRuntimeConfigTypeDataType { + __typename?: 'InvalidRuntimeConfigTypeDataType'; + get: Scalars['String']['output']; + key: Scalars['String']['output']; + want: Scalars['String']['output']; +} + +export interface InvitationType { + __typename?: 'InvitationType'; + /** Invitee information */ + invitee: UserType; + /** User information */ + user: UserType; + /** Workspace information */ + workspace: InvitationWorkspaceType; +} + +export interface InvitationWorkspaceType { + __typename?: 'InvitationWorkspaceType'; + /** Base64 encoded avatar */ + avatar: Scalars['String']['output']; + id: Scalars['ID']['output']; + /** Workspace name */ + name: Scalars['String']['output']; +} + +export interface InviteUserType { + __typename?: 'InviteUserType'; + /** User accepted */ + accepted: Scalars['Boolean']['output']; + /** User avatar url */ + avatarUrl: Maybe; + /** + * User email verified + * @deprecated useless + */ + createdAt: Maybe; + /** User email */ + email: Maybe; + /** User email verified */ + emailVerified: Maybe; + /** User password has been set */ + hasPassword: Maybe; + id: Scalars['ID']['output']; + /** Invite id */ + inviteId: Scalars['String']['output']; + /** User name */ + name: Maybe; + /** User permission in workspace */ + permission: Permission; +} + export enum InvoiceStatus { Draft = 'Draft', Open = 'Open', @@ -128,17 +390,315 @@ export enum InvoiceStatus { Void = 'Void', } +export interface LimitedUserType { + __typename?: 'LimitedUserType'; + /** User email */ + email: Scalars['String']['output']; + /** User password has been set */ + hasPassword: Maybe; +} + export interface ListUserInput { first: InputMaybe; skip: InputMaybe; } +export interface MissingOauthQueryParameterDataType { + __typename?: 'MissingOauthQueryParameterDataType'; + name: Scalars['String']['output']; +} + +export interface Mutation { + __typename?: 'Mutation'; + acceptInviteById: Scalars['Boolean']['output']; + addAdminister: Scalars['Boolean']['output']; + addToEarlyAccess: Scalars['Int']['output']; + addWorkspaceFeature: Scalars['Int']['output']; + cancelSubscription: UserSubscription; + changeEmail: UserType; + changePassword: UserType; + /** Cleanup sessions */ + cleanupCopilotSession: Array; + /** Create a subscription checkout link of stripe */ + createCheckoutSession: Scalars['String']['output']; + /** Create a chat message */ + createCopilotMessage: Scalars['String']['output']; + /** Create a copilot prompt */ + createCopilotPrompt: CopilotPromptType; + /** Create a chat session */ + createCopilotSession: Scalars['String']['output']; + /** Create a stripe customer portal to manage payment methods */ + createCustomerPortal: Scalars['String']['output']; + /** Create a new user */ + createUser: UserType; + /** Create a new workspace */ + createWorkspace: WorkspaceType; + deleteAccount: DeleteAccount; + deleteBlob: Scalars['Boolean']['output']; + /** Delete a user account */ + deleteUser: DeleteAccount; + deleteWorkspace: Scalars['Boolean']['output']; + invite: Scalars['String']['output']; + leaveWorkspace: Scalars['Boolean']['output']; + publishPage: WorkspacePage; + recoverDoc: Scalars['DateTime']['output']; + /** Remove user avatar */ + removeAvatar: RemoveAvatar; + removeEarlyAccess: Scalars['Int']['output']; + removeWorkspaceFeature: Scalars['Int']['output']; + resumeSubscription: UserSubscription; + revoke: Scalars['Boolean']['output']; + /** @deprecated use revokePublicPage */ + revokePage: Scalars['Boolean']['output']; + revokePublicPage: WorkspacePage; + sendChangeEmail: Scalars['Boolean']['output']; + sendChangePasswordEmail: Scalars['Boolean']['output']; + sendSetPasswordEmail: Scalars['Boolean']['output']; + sendVerifyChangeEmail: Scalars['Boolean']['output']; + sendVerifyEmail: Scalars['Boolean']['output']; + setBlob: Scalars['String']['output']; + setWorkspaceExperimentalFeature: Scalars['Boolean']['output']; + /** @deprecated renamed to publishPage */ + sharePage: Scalars['Boolean']['output']; + /** Update a copilot prompt */ + updateCopilotPrompt: CopilotPromptType; + updateProfile: UserType; + /** update server runtime configurable setting */ + updateRuntimeConfig: ServerRuntimeConfigType; + /** update multiple server runtime configurable settings */ + updateRuntimeConfigs: Array; + updateSubscriptionRecurring: UserSubscription; + /** Update workspace */ + updateWorkspace: WorkspaceType; + /** Upload user avatar */ + uploadAvatar: UserType; + verifyEmail: Scalars['Boolean']['output']; +} + +export interface MutationAcceptInviteByIdArgs { + inviteId: Scalars['String']['input']; + sendAcceptMail: InputMaybe; + workspaceId: Scalars['String']['input']; +} + +export interface MutationAddAdministerArgs { + email: Scalars['String']['input']; +} + +export interface MutationAddToEarlyAccessArgs { + email: Scalars['String']['input']; + type: EarlyAccessType; +} + +export interface MutationAddWorkspaceFeatureArgs { + feature: FeatureType; + workspaceId: Scalars['String']['input']; +} + +export interface MutationCancelSubscriptionArgs { + idempotencyKey: Scalars['String']['input']; + plan?: InputMaybe; +} + +export interface MutationChangeEmailArgs { + email: Scalars['String']['input']; + token: Scalars['String']['input']; +} + +export interface MutationChangePasswordArgs { + newPassword: Scalars['String']['input']; + token: Scalars['String']['input']; +} + +export interface MutationCleanupCopilotSessionArgs { + options: DeleteSessionInput; +} + +export interface MutationCreateCheckoutSessionArgs { + input: CreateCheckoutSessionInput; +} + +export interface MutationCreateCopilotMessageArgs { + options: CreateChatMessageInput; +} + +export interface MutationCreateCopilotPromptArgs { + input: CreateCopilotPromptInput; +} + +export interface MutationCreateCopilotSessionArgs { + options: CreateChatSessionInput; +} + +export interface MutationCreateUserArgs { + input: CreateUserInput; +} + +export interface MutationCreateWorkspaceArgs { + init: InputMaybe; +} + +export interface MutationDeleteBlobArgs { + hash: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +} + +export interface MutationDeleteUserArgs { + id: Scalars['String']['input']; +} + +export interface MutationDeleteWorkspaceArgs { + id: Scalars['String']['input']; +} + +export interface MutationInviteArgs { + email: Scalars['String']['input']; + permission: Permission; + sendInviteMail: InputMaybe; + workspaceId: Scalars['String']['input']; +} + +export interface MutationLeaveWorkspaceArgs { + sendLeaveMail: InputMaybe; + workspaceId: Scalars['String']['input']; + workspaceName: Scalars['String']['input']; +} + +export interface MutationPublishPageArgs { + mode?: InputMaybe; + pageId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +} + +export interface MutationRecoverDocArgs { + guid: Scalars['String']['input']; + timestamp: Scalars['DateTime']['input']; + workspaceId: Scalars['String']['input']; +} + +export interface MutationRemoveEarlyAccessArgs { + email: Scalars['String']['input']; +} + +export interface MutationRemoveWorkspaceFeatureArgs { + feature: FeatureType; + workspaceId: Scalars['String']['input']; +} + +export interface MutationResumeSubscriptionArgs { + idempotencyKey: Scalars['String']['input']; + plan?: InputMaybe; +} + +export interface MutationRevokeArgs { + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +} + +export interface MutationRevokePageArgs { + pageId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +} + +export interface MutationRevokePublicPageArgs { + pageId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +} + +export interface MutationSendChangeEmailArgs { + callbackUrl: Scalars['String']['input']; + email: InputMaybe; +} + +export interface MutationSendChangePasswordEmailArgs { + callbackUrl: Scalars['String']['input']; + email: InputMaybe; +} + +export interface MutationSendSetPasswordEmailArgs { + callbackUrl: Scalars['String']['input']; + email: InputMaybe; +} + +export interface MutationSendVerifyChangeEmailArgs { + callbackUrl: Scalars['String']['input']; + email: Scalars['String']['input']; + token: Scalars['String']['input']; +} + +export interface MutationSendVerifyEmailArgs { + callbackUrl: Scalars['String']['input']; +} + +export interface MutationSetBlobArgs { + blob: Scalars['Upload']['input']; + workspaceId: Scalars['String']['input']; +} + +export interface MutationSetWorkspaceExperimentalFeatureArgs { + enable: Scalars['Boolean']['input']; + feature: FeatureType; + workspaceId: Scalars['String']['input']; +} + +export interface MutationSharePageArgs { + pageId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +} + +export interface MutationUpdateCopilotPromptArgs { + messages: Array; + name: Scalars['String']['input']; +} + +export interface MutationUpdateProfileArgs { + input: UpdateUserInput; +} + +export interface MutationUpdateRuntimeConfigArgs { + id: Scalars['String']['input']; + value: Scalars['JSON']['input']; +} + +export interface MutationUpdateRuntimeConfigsArgs { + updates: Scalars['JSONObject']['input']; +} + +export interface MutationUpdateSubscriptionRecurringArgs { + idempotencyKey: Scalars['String']['input']; + plan?: InputMaybe; + recurring: SubscriptionRecurring; +} + +export interface MutationUpdateWorkspaceArgs { + input: UpdateWorkspaceInput; +} + +export interface MutationUploadAvatarArgs { + avatar: Scalars['Upload']['input']; +} + +export interface MutationVerifyEmailArgs { + token: Scalars['String']['input']; +} + +export interface NotInWorkspaceDataType { + __typename?: 'NotInWorkspaceDataType'; + workspaceId: Scalars['String']['output']; +} + export enum OAuthProviderType { GitHub = 'GitHub', Google = 'Google', OIDC = 'OIDC', } +export interface PasswordLimitsType { + __typename?: 'PasswordLimitsType'; + maxLength: Scalars['Int']['output']; + minLength: Scalars['Int']['output']; +} + /** User permission in workspace */ export enum Permission { Admin = 'Admin', @@ -153,6 +713,86 @@ export enum PublicPageMode { Page = 'Page', } +export interface Query { + __typename?: 'Query'; + /** @deprecated no more needed */ + checkBlobSize: WorkspaceBlobSizes; + /** @deprecated use `user.storageUsage` instead */ + collectAllBlobSizes: WorkspaceBlobSizes; + /** Get current user */ + currentUser: Maybe; + earlyAccessUsers: Array; + error: ErrorDataUnion; + /** send workspace invitation */ + getInviteInfo: InvitationType; + /** Get is owner of workspace */ + isOwner: Scalars['Boolean']['output']; + /** + * List blobs of workspace + * @deprecated use `workspace.blobs` instead + */ + listBlobs: Array; + /** List all copilot prompts */ + listCopilotPrompts: Array; + listWorkspaceFeatures: Array; + prices: Array; + /** server config */ + serverConfig: ServerConfigType; + /** get all server runtime configurable settings */ + serverRuntimeConfig: Array; + /** Get user by email */ + user: Maybe; + /** Get user by id */ + userById: UserType; + /** List registered users */ + users: Array; + /** Get workspace by id */ + workspace: WorkspaceType; + /** Get all accessible workspaces for current user */ + workspaces: Array; +} + +export interface QueryCheckBlobSizeArgs { + size: Scalars['SafeInt']['input']; + workspaceId: Scalars['String']['input']; +} + +export interface QueryErrorArgs { + name: ErrorNames; +} + +export interface QueryGetInviteInfoArgs { + inviteId: Scalars['String']['input']; +} + +export interface QueryIsOwnerArgs { + workspaceId: Scalars['String']['input']; +} + +export interface QueryListBlobsArgs { + workspaceId: Scalars['String']['input']; +} + +export interface QueryListWorkspaceFeaturesArgs { + feature: FeatureType; +} + +export interface QueryUserArgs { + email: Scalars['String']['input']; +} + +export interface QueryUserByIdArgs { + id: Scalars['String']['input']; +} + +export interface QueryUsersArgs { + filter: ListUserInput; +} + +export interface QueryWorkspaceArgs { + id: Scalars['String']['input']; +} + export interface QueryChatHistoriesInput { action: InputMaybe; limit: InputMaybe; @@ -160,6 +800,28 @@ export interface QueryChatHistoriesInput { skip: InputMaybe; } +export interface QuotaQueryType { + __typename?: 'QuotaQueryType'; + blobLimit: Scalars['SafeInt']['output']; + copilotActionLimit: Maybe; + historyPeriod: Scalars['SafeInt']['output']; + humanReadable: HumanReadableQuotaType; + memberLimit: Scalars['SafeInt']['output']; + name: Scalars['String']['output']; + storageQuota: Scalars['SafeInt']['output']; + usedSize: Scalars['SafeInt']['output']; +} + +export interface RemoveAvatar { + __typename?: 'RemoveAvatar'; + success: Scalars['Boolean']['output']; +} + +export interface RuntimeConfigNotFoundDataType { + __typename?: 'RuntimeConfigNotFoundDataType'; + key: Scalars['String']['output']; +} + export enum RuntimeConfigType { Array = 'Array', Boolean = 'Boolean', @@ -168,6 +830,37 @@ export enum RuntimeConfigType { String = 'String', } +export interface SameSubscriptionRecurringDataType { + __typename?: 'SameSubscriptionRecurringDataType'; + recurring: Scalars['String']['output']; +} + +export interface ServerConfigType { + __typename?: 'ServerConfigType'; + /** server base url */ + baseUrl: Scalars['String']['output']; + /** credentials requirement */ + credentialsRequirement: CredentialsRequirementType; + /** enable telemetry */ + enableTelemetry: Scalars['Boolean']['output']; + /** enabled server features */ + features: Array; + /** server flags */ + flags: ServerFlagsType; + /** + * server flavor + * @deprecated use `features` + */ + flavor: Scalars['String']['output']; + /** server identical name could be shown as badge on user interface */ + name: Scalars['String']['output']; + oauthProviders: Array; + /** server type */ + type: ServerDeploymentType; + /** server version */ + version: Scalars['String']['output']; +} + export enum ServerDeploymentType { Affine = 'Affine', Selfhosted = 'Selfhosted', @@ -179,6 +872,33 @@ export enum ServerFeature { Payment = 'Payment', } +export interface ServerFlagsType { + __typename?: 'ServerFlagsType'; + earlyAccessControl: Scalars['Boolean']['output']; + syncClientVersionCheck: Scalars['Boolean']['output']; +} + +export interface ServerRuntimeConfigType { + __typename?: 'ServerRuntimeConfigType'; + description: Scalars['String']['output']; + id: Scalars['String']['output']; + key: Scalars['String']['output']; + module: Scalars['String']['output']; + type: RuntimeConfigType; + updatedAt: Scalars['DateTime']['output']; + value: Scalars['JSON']['output']; +} + +export interface SubscriptionAlreadyExistsDataType { + __typename?: 'SubscriptionAlreadyExistsDataType'; + plan: Scalars['String']['output']; +} + +export interface SubscriptionNotExistsDataType { + __typename?: 'SubscriptionNotExistsDataType'; + plan: Scalars['String']['output']; +} + export enum SubscriptionPlan { AI = 'AI', Enterprise = 'Enterprise', @@ -188,6 +908,21 @@ export enum SubscriptionPlan { Team = 'Team', } +export interface SubscriptionPlanNotFoundDataType { + __typename?: 'SubscriptionPlanNotFoundDataType'; + plan: Scalars['String']['output']; + recurring: Scalars['String']['output']; +} + +export interface SubscriptionPrice { + __typename?: 'SubscriptionPrice'; + amount: Maybe; + currency: Scalars['String']['output']; + plan: SubscriptionPlan; + type: Scalars['String']['output']; + yearlyAmount: Maybe; +} + export enum SubscriptionRecurring { Monthly = 'Monthly', Yearly = 'Yearly', @@ -204,6 +939,11 @@ export enum SubscriptionStatus { Unpaid = 'Unpaid', } +export interface UnknownOauthProviderDataType { + __typename?: 'UnknownOauthProviderDataType'; + name: Scalars['String']['output']; +} + export interface UpdateUserInput { /** User name */ name: InputMaybe; @@ -215,6 +955,200 @@ export interface UpdateWorkspaceInput { public: InputMaybe; } +export interface UserInvoice { + __typename?: 'UserInvoice'; + amount: Scalars['Int']['output']; + createdAt: Scalars['DateTime']['output']; + currency: Scalars['String']['output']; + id: Scalars['String']['output']; + lastPaymentError: Maybe; + link: Maybe; + plan: SubscriptionPlan; + reason: Scalars['String']['output']; + recurring: SubscriptionRecurring; + status: InvoiceStatus; + updatedAt: Scalars['DateTime']['output']; +} + +export type UserOrLimitedUser = LimitedUserType | UserType; + +export interface UserQuota { + __typename?: 'UserQuota'; + blobLimit: Scalars['SafeInt']['output']; + historyPeriod: Scalars['SafeInt']['output']; + humanReadable: UserQuotaHumanReadable; + memberLimit: Scalars['Int']['output']; + name: Scalars['String']['output']; + storageQuota: Scalars['SafeInt']['output']; +} + +export interface UserQuotaHumanReadable { + __typename?: 'UserQuotaHumanReadable'; + blobLimit: Scalars['String']['output']; + historyPeriod: Scalars['String']['output']; + memberLimit: Scalars['String']['output']; + name: Scalars['String']['output']; + storageQuota: Scalars['String']['output']; +} + +export interface UserSubscription { + __typename?: 'UserSubscription'; + canceledAt: Maybe; + createdAt: Scalars['DateTime']['output']; + end: Scalars['DateTime']['output']; + id: Scalars['String']['output']; + nextBillAt: Maybe; + /** + * The 'Free' plan just exists to be a placeholder and for the type convenience of frontend. + * There won't actually be a subscription with plan 'Free' + */ + plan: SubscriptionPlan; + recurring: SubscriptionRecurring; + start: Scalars['DateTime']['output']; + status: SubscriptionStatus; + trialEnd: Maybe; + trialStart: Maybe; + updatedAt: Scalars['DateTime']['output']; +} + +export interface UserType { + __typename?: 'UserType'; + /** User avatar url */ + avatarUrl: Maybe; + copilot: Copilot; + /** + * User email verified + * @deprecated useless + */ + createdAt: Maybe; + /** User email */ + email: Scalars['String']['output']; + /** User email verified */ + emailVerified: Scalars['Boolean']['output']; + /** Enabled features of a user */ + features: Array; + /** User password has been set */ + hasPassword: Maybe; + id: Scalars['ID']['output']; + /** Get user invoice count */ + invoiceCount: Scalars['Int']['output']; + invoices: Array; + /** User name */ + name: Scalars['String']['output']; + quota: Maybe; + /** @deprecated use `UserType.subscriptions` */ + subscription: Maybe; + subscriptions: Array; + /** @deprecated use [/api/auth/authorize] */ + token: TokenType; +} + +export interface UserTypeCopilotArgs { + workspaceId: InputMaybe; +} + +export interface UserTypeInvoicesArgs { + skip: InputMaybe; + take?: InputMaybe; +} + +export interface UserTypeSubscriptionArgs { + plan?: InputMaybe; +} + +export interface VersionRejectedDataType { + __typename?: 'VersionRejectedDataType'; + serverVersion: Scalars['String']['output']; + version: Scalars['String']['output']; +} + +export interface WorkspaceAccessDeniedDataType { + __typename?: 'WorkspaceAccessDeniedDataType'; + workspaceId: Scalars['String']['output']; +} + +export interface WorkspaceBlobSizes { + __typename?: 'WorkspaceBlobSizes'; + size: Scalars['SafeInt']['output']; +} + +export interface WorkspaceNotFoundDataType { + __typename?: 'WorkspaceNotFoundDataType'; + workspaceId: Scalars['String']['output']; +} + +export interface WorkspaceOwnerNotFoundDataType { + __typename?: 'WorkspaceOwnerNotFoundDataType'; + workspaceId: Scalars['String']['output']; +} + +export interface WorkspacePage { + __typename?: 'WorkspacePage'; + id: Scalars['String']['output']; + mode: PublicPageMode; + public: Scalars['Boolean']['output']; + workspaceId: Scalars['String']['output']; +} + +export interface WorkspaceType { + __typename?: 'WorkspaceType'; + /** Available features of workspace */ + availableFeatures: Array; + /** List blobs of workspace */ + blobs: Array; + /** Blobs size of workspace */ + blobsSize: Scalars['Int']['output']; + /** Workspace created date */ + createdAt: Scalars['DateTime']['output']; + /** Enabled features of workspace */ + features: Array; + histories: Array; + id: Scalars['ID']['output']; + /** member count of workspace */ + memberCount: Scalars['Int']['output']; + /** Members of workspace */ + members: Array; + /** Owner of workspace */ + owner: UserType; + /** Permission of current signed in user in workspace */ + permission: Permission; + /** is Public workspace */ + public: Scalars['Boolean']['output']; + /** Get public page of a workspace by page id. */ + publicPage: Maybe; + /** Public pages of a workspace */ + publicPages: Array; + /** quota of workspace */ + quota: QuotaQueryType; + /** + * Shared pages of workspace + * @deprecated use WorkspaceType.publicPages + */ + sharedPages: Array; +} + +export interface WorkspaceTypeHistoriesArgs { + before: InputMaybe; + guid: Scalars['String']['input']; + take: InputMaybe; +} + +export interface WorkspaceTypeMembersArgs { + skip: InputMaybe; + take: InputMaybe; +} + +export interface WorkspaceTypePublicPageArgs { + pageId: Scalars['String']['input']; +} + +export interface TokenType { + __typename?: 'tokenType'; + refresh: Scalars['String']['output']; + sessionToken: Maybe; + token: Scalars['String']['output']; +} + export type DeleteBlobMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; hash: Scalars['String']['input'];