diff --git a/packages/server/app.ts b/packages/server/app.ts index 993b621fb..3f4dadaab 100644 --- a/packages/server/app.ts +++ b/packages/server/app.ts @@ -70,7 +70,7 @@ import { buildMocksConfig } from '@/modules/mocks' import { defaultErrorHandler } from '@/modules/core/rest/defaultErrorHandler' import { migrateDbToLatest } from '@/db/migrations' import { statusCodePlugin } from '@/modules/core/graph/plugins/statusCode' -import { BaseError, ForbiddenError } from '@/modules/shared/errors' +import { BadRequestError, BaseError, ForbiddenError } from '@/modules/shared/errors' import { loggingPluginFactory } from '@/modules/core/graph/plugins/logging' import { shouldLogAsInfoLevel } from '@/logging/graphqlError' import { getUserFactory } from '@/modules/core/repositories/users' @@ -247,12 +247,12 @@ export function buildApolloSubscriptionServer( } if (!header) { - throw new Error("Couldn't resolve auth header for subscription") + throw new BadRequestError("Couldn't resolve auth header for subscription") } token = header.split(' ')[1] if (!token) { - throw new Error("Couldn't resolve token from auth header") + throw new BadRequestError("Couldn't resolve token from auth header") } } catch (e) { throw new ForbiddenError('You need a token to subscribe') diff --git a/packages/server/modules/auth/errors/index.ts b/packages/server/modules/auth/errors/index.ts index 995b66544..e0470b944 100644 --- a/packages/server/modules/auth/errors/index.ts +++ b/packages/server/modules/auth/errors/index.ts @@ -5,3 +5,33 @@ export class InvalidAccessCodeRequestError extends BaseError { static defaultMessage = 'An issue occurred while generating an access code for an app' static statusCode = 400 } + +export class AppCreateError extends BaseError { + static code = 'APP_CREATE' + static defaultMessage = 'An issue occurred while creating an app' + static statusCode = 400 +} + +export class AccessCodeNotFoundError extends BaseError { + static code = 'ACCESS_CODE_NOT_FOUND' + static defaultMessage = 'An issue occurred while trying to find the access code' + static statusCode = 404 +} + +export class AppTokenCreateError extends BaseError { + static code = 'APP_TOKEN_CREATE' + static defaultMessage = 'An issue occurred while creating an app token' + static statusCode = 400 +} + +export class RefreshTokenNotFound extends BaseError { + static code = 'REFRESH_TOKEN_NOT_FOUND' + static defaultMessage = 'An issue occurred while trying to find the refresh token' + static statusCode = 404 +} + +export class RefreshTokenError extends BaseError { + static code = 'REFRESH_TOKEN' + static defaultMessage = 'An issue occurred while refreshing a token' + static statusCode = 400 +} diff --git a/packages/server/modules/auth/middleware.ts b/packages/server/modules/auth/middleware.ts index 5a88df192..7877cf12c 100644 --- a/packages/server/modules/auth/middleware.ts +++ b/packages/server/modules/auth/middleware.ts @@ -14,6 +14,7 @@ import { CreateAuthorizationCode } from '@/modules/auth/domain/operations' import { authLogger } from '@/logging/logging' import { ensureError } from '@speckle/shared' import { LegacyGetUser } from '@/modules/core/domain/users/operations' +import { ForbiddenError } from '@/modules/shared/errors' export const sessionMiddlewareFactory = (): RequestHandler => { const RedisStore = ConnectRedis(ExpressSession) @@ -69,7 +70,7 @@ export const finalizeAuthMiddlewareFactory = async (req, res) => { try { if (!req.user) { - throw new Error('Cannot finalize auth - No user attached to session') + throw new ForbiddenError('Cannot finalize auth - No user attached to session') } const ac = await deps.createAuthorizationCode({ diff --git a/packages/server/modules/auth/repositories/apps.ts b/packages/server/modules/auth/repositories/apps.ts index 04ac31ff0..2ebbe6fd8 100644 --- a/packages/server/modules/auth/repositories/apps.ts +++ b/packages/server/modules/auth/repositories/apps.ts @@ -47,6 +47,8 @@ import { ServerAppRecord, UserRecord } from '@/modules/core/helpers/types' import cryptoRandomString from 'crypto-random-string' import { Knex } from 'knex' import { difference, omit } from 'lodash' +import { AppCreateError } from '@/modules/auth/errors' +import { UserInputError } from '@/modules/core/errors/userinput' const tables = { serverApps: (db: Knex) => db(ServerApps.name), @@ -276,7 +278,7 @@ export const createAppFactory = const scopes = (app.scopes || []).filter((s) => !!s?.length) if (!scopes.length) { - throw new Error('Cannot create an app with no scopes.') + throw new AppCreateError('Cannot create an app with no scopes.') } const insertableApp = { @@ -376,7 +378,7 @@ export const revokeRefreshTokenFactory = export const createAuthorizationCodeFactory = (deps: { db: Knex }): CreateAuthorizationCode => async ({ appId, userId, challenge }) => { - if (!challenge) throw new Error('Please provide a valid challenge.') + if (!challenge) throw new UserInputError('Please provide a valid challenge.') const ac = { id: cryptoRandomString({ length: 42 }), diff --git a/packages/server/modules/auth/rest/index.ts b/packages/server/modules/auth/rest/index.ts index 59f700051..3733b9ae7 100644 --- a/packages/server/modules/auth/rest/index.ts +++ b/packages/server/modules/auth/rest/index.ts @@ -7,7 +7,7 @@ import { import { validateScopes } from '@/modules/shared' import { InvalidAccessCodeRequestError } from '@/modules/auth/errors' import { ensureError, Optional, Scopes } from '@speckle/shared' -import { ForbiddenError } from '@/modules/shared/errors' +import { BadRequestError, ForbiddenError } from '@/modules/shared/errors' import { getAppFactory, revokeRefreshTokenFactory, @@ -144,7 +144,7 @@ export default function (app: Express) { // Token refresh if (req.body.refreshToken) { if (!req.body.appId || !req.body.appSecret) - throw new Error('Invalid request - App Id and Secret are required.') + throw new BadRequestError('Invalid request - App Id and Secret are required.') const authResponse = await refreshAppToken({ refreshToken: req.body.refreshToken, @@ -161,7 +161,7 @@ export default function (app: Express) { !req.body.accessCode || !req.body.challenge ) - throw new Error( + throw new BadRequestError( `Invalid request, insufficient information provided in the request. App Id, Secret, Access Code, and Challenge are required.` ) @@ -189,7 +189,7 @@ export default function (app: Express) { const token = req.body.token const refreshToken = req.body.refreshToken - if (!token) throw new Error('Invalid request. No token provided.') + if (!token) throw new BadRequestError('Invalid request. No token provided.') await revokeTokenById(token) if (refreshToken) await revokeRefreshToken({ tokenId: refreshToken }) diff --git a/packages/server/modules/auth/services/mailchimp.ts b/packages/server/modules/auth/services/mailchimp.ts index e2341a4b9..819e8535d 100644 --- a/packages/server/modules/auth/services/mailchimp.ts +++ b/packages/server/modules/auth/services/mailchimp.ts @@ -3,13 +3,17 @@ import mailchimp from '@mailchimp/mailchimp_marketing' import { md5 } from '@/modules/shared/helpers/cryptoHelper' import { getMailchimpConfig } from '@/modules/shared/helpers/envHelper' import { UserRecord } from '@/modules/core/helpers/types' +import { MisconfiguredEnvironmentError } from '@/modules/shared/errors' let mailchimpInitialized = false function initializeMailchimp() { if (mailchimpInitialized) return const config = getMailchimpConfig() // Note: throws an error if not configured - if (!config) throw new Error('Cannot initialize mailchimp without config values') + if (!config) + throw new MisconfiguredEnvironmentError( + 'Cannot initialize mailchimp without config values' + ) mailchimp.setConfig({ apiKey: config.apiKey, diff --git a/packages/server/modules/auth/services/serverApps.ts b/packages/server/modules/auth/services/serverApps.ts index d539d5c7f..14e79b72e 100644 --- a/packages/server/modules/auth/services/serverApps.ts +++ b/packages/server/modules/auth/services/serverApps.ts @@ -17,6 +17,13 @@ import { CreateAndStoreAppToken } from '@/modules/core/domain/tokens/operations' import { createBareToken } from '@/modules/core/services/tokens' import { ServerScope } from '@speckle/shared' import bcrypt from 'bcrypt' +import { + AccessCodeNotFoundError, + AppTokenCreateError, + RefreshTokenError, + RefreshTokenNotFound +} from '@/modules/auth/errors' +import { ResourceMismatch } from '@/modules/shared/errors' /** * Cached all scopes. Caching occurs on first initializeDefaultApps() call @@ -71,23 +78,24 @@ export const createAppTokenFromAccessCodeFactory = async ({ appId, appSecret, accessCode, challenge }) => { const code = await deps.getAuthorizationCode({ id: accessCode }) - if (!code) throw new Error('Access code not found.') + if (!code) throw new AccessCodeNotFoundError('Access code not found.') if (code.appId !== appId) - throw new Error('Invalid request: application id does not match.') + throw new ResourceMismatch('Invalid request: application id does not match.') await deps.deleteAuthorizationCode({ id: accessCode }) const timeDiff = Math.abs(Date.now() - new Date(code.createdAt).getTime()) if (timeDiff > code.lifespan) { - throw new Error('Access code expired') + throw new AppTokenCreateError('Access code expired') } - if (code.challenge !== challenge) throw new Error('Invalid request') + if (code.challenge !== challenge) throw new AppTokenCreateError('Invalid request') const app = await deps.getApp({ id: appId }) - if (!app) throw new Error('Invalid app') - if (app.secret !== appSecret) throw new Error('Invalid app credentials') + if (!app) throw new AppTokenCreateError('Invalid app') + if (app.secret !== appSecret) + throw new AppTokenCreateError('Invalid app credentials') const appScopes = app.scopes.map((s) => s.name) @@ -132,21 +140,21 @@ export const refreshAppTokenFactory = const refreshTokenDb = await deps.getRefreshToken({ id: refreshTokenId }) - if (!refreshTokenDb) throw new Error('Invalid request') + if (!refreshTokenDb) throw new RefreshTokenNotFound('Invalid request') - if (refreshTokenDb.appId !== appId) throw new Error('Invalid request') + if (refreshTokenDb.appId !== appId) throw new ResourceMismatch('Invalid request') const timeDiff = Math.abs(Date.now() - new Date(refreshTokenDb.createdAt).getTime()) if (timeDiff > refreshTokenDb.lifespan) { await deps.revokeRefreshToken({ tokenId: refreshTokenId }) - throw new Error('Refresh token expired') + throw new RefreshTokenError('Refresh token expired') } const valid = await bcrypt.compare(refreshTokenContent, refreshTokenDb.tokenDigest) - if (!valid) throw new Error('Invalid token') // sneky hackstors + if (!valid) throw new RefreshTokenError('Invalid token') // sneky hackstors const app = await deps.getApp({ id: appId }) - if (!app || app.secret !== appSecret) throw new Error('Invalid request') + if (!app || app.secret !== appSecret) throw new RefreshTokenError('Invalid request') // Create the new token const appToken = await deps.createAppToken({ diff --git a/packages/server/modules/auth/strategies/azureAd.ts b/packages/server/modules/auth/strategies/azureAd.ts index 7fb1ce7f8..597806b79 100644 --- a/packages/server/modules/auth/strategies/azureAd.ts +++ b/packages/server/modules/auth/strategies/azureAd.ts @@ -31,6 +31,7 @@ import { LegacyGetUserByEmail } from '@/modules/core/domain/users/operations' import { GetServerInfo } from '@/modules/core/domain/server/operations' +import { EnvironmentResourceError } from '@/modules/shared/errors' const azureAdStrategyBuilderFactory = (deps: { @@ -103,7 +104,7 @@ const azureAdStrategyBuilderFactory = // than to refactor everything const profile = req.user as Optional if (!profile) { - throw new Error('No profile provided by Entra ID') + throw new EnvironmentResourceError('No profile provided by Entra ID') } logger = logger.child({ profileId: profile.oid }) diff --git a/packages/server/modules/auth/strategies/github.ts b/packages/server/modules/auth/strategies/github.ts index be0207e0e..18e41d862 100644 --- a/packages/server/modules/auth/strategies/github.ts +++ b/packages/server/modules/auth/strategies/github.ts @@ -32,6 +32,7 @@ import { } from '@/modules/core/domain/users/operations' import crs from 'crypto-random-string' import { GetServerInfo } from '@/modules/core/domain/server/operations' +import { EnvironmentResourceError } from '@/modules/shared/errors' const githubStrategyBuilderFactory = (deps: { @@ -87,7 +88,7 @@ const githubStrategyBuilderFactory = try { const email = profile.emails?.[0].value if (!email) { - throw new Error('No email provided by Github') + throw new EnvironmentResourceError('No email provided by Github') } const name = profile.displayName || profile.username || crs({ length: 10 }) diff --git a/packages/server/modules/auth/strategies/google.ts b/packages/server/modules/auth/strategies/google.ts index c814958c1..3ede2053d 100644 --- a/packages/server/modules/auth/strategies/google.ts +++ b/packages/server/modules/auth/strategies/google.ts @@ -27,6 +27,7 @@ import { LegacyGetUserByEmail } from '@/modules/core/domain/users/operations' import { GetServerInfo } from '@/modules/core/domain/server/operations' +import { EnvironmentResourceError } from '@/modules/shared/errors' const googleStrategyBuilderFactory = (deps: { @@ -72,7 +73,7 @@ const googleStrategyBuilderFactory = try { const email = profile.emails?.[0].value if (!email) { - throw new Error('No email provided by Google') + throw new EnvironmentResourceError('No email provided by Google') } const name = profile.displayName diff --git a/packages/server/modules/auth/strategies/oidc.ts b/packages/server/modules/auth/strategies/oidc.ts index 76386fbde..672b53315 100644 --- a/packages/server/modules/auth/strategies/oidc.ts +++ b/packages/server/modules/auth/strategies/oidc.ts @@ -27,6 +27,7 @@ import { LegacyGetUserByEmail } from '@/modules/core/domain/users/operations' import { GetServerInfo } from '@/modules/core/domain/server/operations' +import { EnvironmentResourceError } from '@/modules/shared/errors' const oidcStrategyBuilderFactory = (deps: { @@ -79,7 +80,9 @@ const oidcStrategyBuilderFactory = try { const email = userinfo['email'] if (!email) { - throw new Error('No email provided by the OIDC provider.') + throw new EnvironmentResourceError( + 'No email provided by the OIDC provider.' + ) } const name = getNameFromUserInfo(userinfo) @@ -108,7 +111,7 @@ const oidcStrategyBuilderFactory = // if the server is invite only and we have no invite id, throw. if (serverInfo.inviteOnly && !token) { - throw new Error( + throw new EnvironmentResourceError( 'This server is invite only. Please provide an invite id.' ) } diff --git a/packages/server/modules/automate/errors/functions.ts b/packages/server/modules/automate/errors/functions.ts new file mode 100644 index 000000000..ba4e23982 --- /dev/null +++ b/packages/server/modules/automate/errors/functions.ts @@ -0,0 +1,7 @@ +import { BaseError } from '@/modules/shared/errors' + +export class UnknownFunctionTemplateError extends BaseError { + static defaultMessage = 'Unknown function template' + static code = 'UNKNOWN_FUNCTION_TEMPLATE' + static statusCode = 400 +} diff --git a/packages/server/modules/automate/graph/resolvers/automate.ts b/packages/server/modules/automate/graph/resolvers/automate.ts index 0e9f83099..a903a1a8c 100644 --- a/packages/server/modules/automate/graph/resolvers/automate.ts +++ b/packages/server/modules/automate/graph/resolvers/automate.ts @@ -124,6 +124,7 @@ import { } from '@/modules/core/repositories/tokens' import { getEventBus } from '@/modules/shared/services/eventBus' import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' +import { BranchNotFoundError } from '@/modules/core/errors/branch' const { FF_AUTOMATE_MODULE_ENABLED } = getFeatureFlags() @@ -287,7 +288,7 @@ export = (FF_AUTOMATE_MODULE_ENABLED const branch = await ctx.loaders .forRegion({ db: projectDb }) .commits.getCommitBranch.load(versionId) - if (!branch) throw Error('Invalid version Id') + if (!branch) throw new BranchNotFoundError('Invalid version Id') const projectId = branch.streamId const modelId = branch.id diff --git a/packages/server/modules/automate/index.ts b/packages/server/modules/automate/index.ts index c8a79fa83..4547c2d98 100644 --- a/packages/server/modules/automate/index.ts +++ b/packages/server/modules/automate/index.ts @@ -55,6 +55,7 @@ import { getProjectFactory } from '@/modules/core/repositories/projects' import { getEventBus } from '@/modules/shared/services/eventBus' import { VersionEvents } from '@/modules/core/domain/commits/events' import { AutomationEvents, AutomationRunEvents } from '@/modules/automate/domain/events' +import { LogicError } from '@/modules/shared/errors' const { FF_AUTOMATE_MODULE_ENABLED } = getFeatureFlags() let quitListeners: Optional<() => void> = undefined @@ -267,7 +268,7 @@ const initializeEventListeners = () => { db: projectDb })(run.automationRevisionId) const fullRun = await getFullAutomationRunByIdFactory({ db: projectDb })(run.id) - if (!fullRun) throw new Error('This should never happen') + if (!fullRun) throw new LogicError('This should never happen') if (!automationWithRevision) { automateLogger.error( diff --git a/packages/server/modules/automate/rest/logStream.ts b/packages/server/modules/automate/rest/logStream.ts index 5d5fdea15..46a9f4d56 100644 --- a/packages/server/modules/automate/rest/logStream.ts +++ b/packages/server/modules/automate/rest/logStream.ts @@ -16,6 +16,7 @@ import { authMiddlewareCreator } from '@/modules/shared/middleware' import { getRolesFactory } from '@/modules/shared/repositories/roles' import { Roles, Scopes } from '@speckle/shared' import { Application } from 'express' +import { FunctionRunNotFoundError } from '@/modules/automate/errors/runs' export default (app: Application) => { app.get( @@ -47,10 +48,12 @@ export default (app: Application) => { automationRunId: runId }) if (!run) { - throw new Error("Couldn't find automation or its run") + throw new FunctionRunNotFoundError("Couldn't find automation or its run") } if (!run.executionEngineRunId) { - throw new Error('No associated run found on the execution engine') + throw new FunctionRunNotFoundError( + 'No associated run found on the execution engine' + ) } const setPlaintextHeaders = () => { diff --git a/packages/server/modules/automate/services/functionManagement.ts b/packages/server/modules/automate/services/functionManagement.ts index 662008541..564bfe9fa 100644 --- a/packages/server/modules/automate/services/functionManagement.ts +++ b/packages/server/modules/automate/services/functionManagement.ts @@ -47,6 +47,8 @@ import { automateLogger } from '@/logging/logging' import { CreateStoredAuthCode } from '@/modules/automate/domain/operations' import { GetUser } from '@/modules/core/domain/users/operations' import { noop } from 'lodash' +import { UnknownFunctionTemplateError } from '@/modules/automate/errors/functions' +import { UserInputError } from '@/modules/core/errors/userinput' const mapGqlTemplateIdToExecEngineTemplateId = ( id: AutomateFunctionTemplateLanguage @@ -59,7 +61,7 @@ const mapGqlTemplateIdToExecEngineTemplateId = ( case AutomateFunctionTemplateLanguage.Typescript: return ExecutionEngineFunctionTemplateId.TypeScript default: - throw new Error('Unknown template id') + throw new UnknownFunctionTemplateError('Unknown template id') } } @@ -69,7 +71,7 @@ const repoUrlToBasicGitRepositoryMetadata = ( const repoUrl = new URL(url) const pathParts = repoUrl.pathname.split('/').filter(Boolean) if (pathParts.length < 2) { - throw new Error('Invalid GitHub repository URL') + throw new UserInputError('Invalid GitHub repository URL') } const [owner, name] = pathParts diff --git a/packages/server/modules/automate/services/tracking.ts b/packages/server/modules/automate/services/tracking.ts index 179c59701..345258ab9 100644 --- a/packages/server/modules/automate/services/tracking.ts +++ b/packages/server/modules/automate/services/tracking.ts @@ -5,6 +5,7 @@ import { import { InsertableAutomationRun } from '@/modules/automate/repositories/automations' import { GetCommit } from '@/modules/core/domain/commits/operations' import { LegacyGetUser } from '@/modules/core/domain/users/operations' +import { CommitNotFoundError } from '@/modules/core/errors/commit' import { throwUncoveredError } from '@speckle/shared' export type AutomateTrackingDeps = { @@ -27,7 +28,7 @@ export const getUserEmailFromAutomationRunFactory = const version = await deps.getCommit(trigger.triggeringId, { streamId: projectId }) - if (!version) throw new Error("Version doesn't exist any more") + if (!version) throw new CommitNotFoundError("Version doesn't exist any more") const userId = version.author if (userId) { const user = await deps.getUser(userId) diff --git a/packages/server/modules/cli/commands/bull/test-consume.ts b/packages/server/modules/cli/commands/bull/test-consume.ts index b3d0d18b5..3db0e8b25 100644 --- a/packages/server/modules/cli/commands/bull/test-consume.ts +++ b/packages/server/modules/cli/commands/bull/test-consume.ts @@ -1,6 +1,7 @@ import { cliLogger } from '@/logging/logging' import { NotificationType } from '@/modules/notifications/helpers/types' import { initializeConsumption } from '@/modules/notifications/index' +import { EnvironmentResourceError } from '@/modules/shared/errors' import { get, noop } from 'lodash' import { CommandModule } from 'yargs' @@ -16,7 +17,7 @@ const command: CommandModule = { logger.info('Received test message with payload', msg, job) if (get(msg.data, 'error')) { - throw new Error('Forced to throw error!') + throw new EnvironmentResourceError('Forced to throw error!') } } }) diff --git a/packages/server/modules/cli/commands/db/migrate/create.ts b/packages/server/modules/cli/commands/db/migrate/create.ts index a4d75e871..f2b365b5a 100644 --- a/packages/server/modules/cli/commands/db/migrate/create.ts +++ b/packages/server/modules/cli/commands/db/migrate/create.ts @@ -4,6 +4,7 @@ import fs from 'fs/promises' import { logger } from '@/logging/logging' import { CommandModule } from 'yargs' import { ensureError } from '@speckle/shared' +import { MisconfiguredEnvironmentError } from '@/modules/shared/errors' /** @type {import('yargs').CommandModule} */ const command: CommandModule = { @@ -27,13 +28,14 @@ const command: CommandModule = { try { await fs.access(migrationDir) } catch (e) { - if (ensureError(e).message.toLowerCase().includes('no such file or directory')) { + const cause = ensureError(e) + if (cause.message.toLowerCase().includes('no such file or directory')) { // Try to create it await fs.mkdir(migrationDir, { recursive: true }) } else { - throw new Error( + throw new MisconfiguredEnvironmentError( `Migration directory '${migrationDir}' is not accessible! Check if it exists.`, - { cause: e } + { cause } ) } } diff --git a/packages/server/modules/cli/commands/db/seed/commits.ts b/packages/server/modules/cli/commands/db/seed/commits.ts index 37733566a..77247f082 100644 --- a/packages/server/modules/cli/commands/db/seed/commits.ts +++ b/packages/server/modules/cli/commands/db/seed/commits.ts @@ -1,7 +1,10 @@ import { db } from '@/db/knex' import { cliLogger } from '@/logging/logging' +import { StreamNotFoundError } from '@/modules/core/errors/stream' +import { UserNotFoundError } from '@/modules/core/errors/user' import { getStreamFactory } from '@/modules/core/repositories/streams' import { getUserFactory } from '@/modules/core/repositories/users' +import { ForbiddenError } from '@/modules/shared/errors' import { BasicTestCommit, createTestCommits } from '@/test/speckle-helpers/commitHelper' import dayjs from 'dayjs' import { times } from 'lodash' @@ -39,15 +42,15 @@ const command: CommandModule< const user = await getUser(authorId) if (!user?.id) { - throw new Error(`User with ID ${authorId} not found`) + throw new UserNotFoundError(`User with ID ${authorId} not found`) } const stream = await getStream({ streamId, userId: user.id }) if (!stream?.id) { - throw new Error(`Stream with ID ${streamId} not found`) + throw new StreamNotFoundError(`Stream with ID ${streamId} not found`) } if (!stream.isPublic && !stream.role) { - throw new Error( + throw new ForbiddenError( `Commit author does not have access to the specified stream ${streamId}` ) } diff --git a/packages/server/modules/cli/commands/workspaces/set-plan.ts b/packages/server/modules/cli/commands/workspaces/set-plan.ts index 301e1c414..534205304 100644 --- a/packages/server/modules/cli/commands/workspaces/set-plan.ts +++ b/packages/server/modules/cli/commands/workspaces/set-plan.ts @@ -5,6 +5,7 @@ import { db } from '@/db/knex' import { PaidWorkspacePlanStatuses } from '@/modules/gatekeeper/domain/billing' import { upsertPaidWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing' import { PaidWorkspacePlans } from '@/modules/gatekeeper/domain/workspacePricing' +import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' const command: CommandModule< unknown, @@ -47,7 +48,9 @@ const command: CommandModule< ) const workspace = await getWorkspaceBySlugOrIdFactory({ db })(args) if (!workspace) { - throw new Error(`Workspace w/ slug or id '${args.workspaceSlugOrId}' not found`) + throw new WorkspaceNotFoundError( + `Workspace w/ slug or id '${args.workspaceSlugOrId}' not found` + ) } await upsertPaidWorkspacePlanFactory({ db })({ diff --git a/packages/server/modules/comments/errors/index.ts b/packages/server/modules/comments/errors/index.ts index 945fef02e..49d4b5043 100644 --- a/packages/server/modules/comments/errors/index.ts +++ b/packages/server/modules/comments/errors/index.ts @@ -6,6 +6,12 @@ export class InvalidAttachmentsError extends BaseError { static statusCode = 400 } +export class CommentNotFoundError extends BaseError { + static defaultMessage = 'Comment not found' + static code = 'COMMENT_NOT_FOUND' + static statusCode = 404 +} + export class CommentCreateError extends BaseError { static defaultMessage = 'An error occurred while creating a new comment' static code = 'COMMENT_CREATE_ERROR' diff --git a/packages/server/modules/comments/repositories/comments.ts b/packages/server/modules/comments/repositories/comments.ts index 887a2d26f..af7df45cd 100644 --- a/packages/server/modules/comments/repositories/comments.ts +++ b/packages/server/modules/comments/repositories/comments.ts @@ -64,6 +64,10 @@ import { import { ExtendedComment } from '@/modules/comments/domain/types' import { BranchLatestCommit } from '@/modules/core/domain/commits/types' import { getBranchLatestCommitsFactory } from '@/modules/core/repositories/branches' +import { CommitNotFoundError } from '@/modules/core/errors/commit' +import { ResourceMismatch } from '@/modules/shared/errors' +import { ObjectNotFoundError } from '@/modules/core/errors/object' +import { CommentNotFoundError } from '@/modules/comments/errors' const tables = { streamCommits: (db: Knex) => db(StreamCommits.name), @@ -740,9 +744,9 @@ export const checkStreamResourceAccessFactory = .select() .where({ commitId: res.resourceId, streamId }) .first() - if (!linkage) throw new Error('Commit not found') + if (!linkage) throw new CommitNotFoundError('Commit not found') if (linkage.streamId !== streamId) - throw new Error( + throw new ResourceMismatch( 'Stop hacking - that commit id is not part of the specified stream.' ) break @@ -753,7 +757,7 @@ export const checkStreamResourceAccessFactory = .select() .where({ id: res.resourceId, streamId }) .first() - if (!obj) throw new Error('Object not found') + if (!obj) throw new ObjectNotFoundError('Object not found') break } case 'comment': { @@ -761,15 +765,15 @@ export const checkStreamResourceAccessFactory = .comments(deps.db) .where({ id: res.resourceId }) .first() - if (!comment) throw new Error('Comment not found') + if (!comment) throw new CommentNotFoundError('Comment not found') if (comment.streamId !== streamId) - throw new Error( + throw new ResourceMismatch( 'Stop hacking - that comment is not part of the specified stream.' ) break } default: - throw Error( + throw new ResourceMismatch( `resource type ${res.resourceType} is not supported as a comment target` ) } diff --git a/packages/server/modules/comments/services/index.ts b/packages/server/modules/comments/services/index.ts index 5c0fc9bd9..6fff95514 100644 --- a/packages/server/modules/comments/services/index.ts +++ b/packages/server/modules/comments/services/index.ts @@ -1,5 +1,5 @@ import crs from 'crypto-random-string' -import { ForbiddenError } from '@/modules/shared/errors' +import { ForbiddenError, ResourceMismatch } from '@/modules/shared/errors' import { buildCommentTextFromInput } from '@/modules/comments/services/commentTextService' import { isNonNullable, Roles } from '@speckle/shared' import { @@ -26,6 +26,8 @@ import { GetStream } from '@/modules/core/domain/streams/operations' import { EventBusEmit } from '@/modules/shared/services/eventBus' import { CommentEvents } from '@/modules/comments/domain/events' import { JSONContent } from '@tiptap/core' +import { UserInputError } from '@/modules/core/errors/userinput' +import { CommentNotFoundError } from '@/modules/comments/errors' export const streamResourceCheckFactory = (deps: { @@ -59,19 +61,24 @@ export const createCommentFactory = }) => async ({ userId, input }: { userId: string; input: CommentCreateInput }) => { if (input.resources.length < 1) - throw Error('Must specify at least one resource as the comment target') + throw new UserInputError( + 'Must specify at least one resource as the comment target' + ) const commentResource = input.resources.find((r) => r?.resourceType === 'comment') - if (commentResource) throw new Error('Please use the comment reply mutation.') + if (commentResource) + throw new UserInputError('Please use the comment reply mutation.') // Stream checks const streamResources = input.resources.filter((r) => r?.resourceType === 'stream') if (streamResources.length > 1) - throw Error('Commenting on multiple streams is not supported') + throw new UserInputError('Commenting on multiple streams is not supported') const [stream] = streamResources if (stream && stream.resourceId !== input.streamId) - throw Error("Input streamId doesn't match the stream resource.resourceId") + throw new ResourceMismatch( + "Input streamId doesn't match the stream resource.resourceId" + ) const comment = { streamId: input.streamId, @@ -218,7 +225,7 @@ export const editCommentFactory = matchUser: boolean }) => { const editedComment = await deps.getComment({ id: input.id }) - if (!editedComment) throw new Error("The comment doesn't exist") + if (!editedComment) throw new CommentNotFoundError("The comment doesn't exist") if (matchUser && editedComment.authorId !== userId) throw new ForbiddenError("You cannot edit someone else's comments") @@ -263,7 +270,7 @@ export const archiveCommentFactory = }) => { const comment = await deps.getComment({ id: commentId }) if (!comment) - throw new Error( + throw new CommentNotFoundError( `No comment ${commentId} exists, cannot change its archival status` ) diff --git a/packages/server/modules/core/errors/object.ts b/packages/server/modules/core/errors/object.ts index 423107b94..b3e10224e 100644 --- a/packages/server/modules/core/errors/object.ts +++ b/packages/server/modules/core/errors/object.ts @@ -5,3 +5,9 @@ export class ObjectHandlingError extends BaseError { static code = 'OBJECT_HANDLING_ERROR' static statusCode = 400 } + +export class ObjectNotFoundError extends BaseError { + static defaultMessage = 'Object not found.' + static code = 'OBJECT_NOT_FOUND' + static statusCode = 404 +} diff --git a/packages/server/modules/core/errors/tokens.ts b/packages/server/modules/core/errors/tokens.ts new file mode 100644 index 000000000..a49b6fd61 --- /dev/null +++ b/packages/server/modules/core/errors/tokens.ts @@ -0,0 +1,7 @@ +import { BaseError } from '@/modules/shared/errors' + +export class TokenRevokationError extends BaseError { + static code = 'TOKEN_REVOKATION_ERROR' + static defaultMessage = 'Token revokation failed' + static statusCode = 400 +} diff --git a/packages/server/modules/core/errors/user.ts b/packages/server/modules/core/errors/user.ts index 893379e43..b9d686e07 100644 --- a/packages/server/modules/core/errors/user.ts +++ b/packages/server/modules/core/errors/user.ts @@ -1,5 +1,11 @@ import { BaseError } from '@/modules/shared/errors/base' +export class UserCreateError extends BaseError { + static defaultMessage = 'An issue occurred while attempting to create a user' + static code = 'USER_CREATE_ERROR' + static statusCode = 500 +} + export class UserUpdateError extends BaseError { static defaultMessage = 'An issue occurred while attempting to update a user' static code = 'USER_UPDATE_ERROR' @@ -17,3 +23,9 @@ export class TokenCreateError extends BaseError { static defaultMessage = 'An error occurred while creating a token' static statusCode = 400 } + +export class UserNotFoundError extends BaseError { + static defaultMessage = 'User not found' + static code = 'USER_NOT_FOUND' + static statusCode = 400 +} diff --git a/packages/server/modules/core/helpers/scanTable.ts b/packages/server/modules/core/helpers/scanTable.ts index 0e0830bcf..e6526d99e 100644 --- a/packages/server/modules/core/helpers/scanTable.ts +++ b/packages/server/modules/core/helpers/scanTable.ts @@ -1,3 +1,4 @@ +import { LogicError } from '@/modules/shared/errors' import { Knex } from 'knex' export const scanTableFactory = ({ @@ -19,7 +20,7 @@ export const scanTableFactory = ({ offset += batchSize if (offset > failsafeLimit) { - throw new Error('Never ending loop') + throw new LogicError('Never ending loop') } } while (rows.length > 0) } diff --git a/packages/server/modules/core/repositories/objects.ts b/packages/server/modules/core/repositories/objects.ts index 851bf78fa..120a91e72 100644 --- a/packages/server/modules/core/repositories/objects.ts +++ b/packages/server/modules/core/repositories/objects.ts @@ -24,6 +24,7 @@ import { import { SpeckleObject } from '@/modules/core/domain/objects/types' import { SetOptional } from 'type-fest' import { get, set, toNumber } from 'lodash' +import { UserInputError } from '@/modules/core/errors/userinput' const ObjectChildrenClosure = buildTableHelper('object_children_closure', [ 'parent', @@ -386,7 +387,7 @@ export const getObjectChildrenQueryFactory = if (typeof statement.value === 'number') castType = 'numeric' if (operatorsWhitelist.indexOf(statement.operator) === -1) - throw new Error('Invalid operator for query') + throw new UserInputError('Invalid operator for query') // Determine the correct where clause (where, and where, or where) let whereClause: keyof typeof nestedWhereQuery @@ -442,7 +443,7 @@ export const getObjectChildrenQueryFactory = if (castType === 'text') cursor.value = `"${cursor.value}"` if (operatorsWhitelist.indexOf(cursor.operator) === -1) - throw new Error('Invalid operator for cursor') + throw new UserInputError('Invalid operator for cursor') // Unwrapping the tuple comparison of ( userOrderByField, id ) > ( lastValueOfUserOrderBy, lastSeenId ) if (fullObjectSelect) { diff --git a/packages/server/modules/core/repositories/streams.ts b/packages/server/modules/core/repositories/streams.ts index 1dd47bb8d..2e19aaf90 100644 --- a/packages/server/modules/core/repositories/streams.ts +++ b/packages/server/modules/core/repositories/streams.ts @@ -21,7 +21,7 @@ import { Branches, ServerAcl } from '@/modules/core/dbSchema' -import { InvalidArgumentError } from '@/modules/shared/errors' +import { InvalidArgumentError, LogicError } from '@/modules/shared/errors' import { Roles, StreamRoles } from '@/modules/core/helpers/mainConstants' import { StreamAclRecord, @@ -44,6 +44,7 @@ import { Knex } from 'knex' import { isProjectCreateInput } from '@/modules/core/helpers/stream' import { StreamAccessUpdateError, + StreamNotFoundError, StreamUpdateError } from '@/modules/core/errors/stream' import { metaHelpers } from '@/modules/core/helpers/meta' @@ -961,7 +962,7 @@ export const updateProjectFactory = const updatedStream = await updateStreamFactory({ db })(projectUpdate) if (!updatedStream) { - throw new StreamUpdateError() + throw new StreamUpdateError('Stream was not updated.') } return updatedStream @@ -1160,7 +1161,7 @@ export const markOnboardingBaseStreamFactory = async (streamId: string, version: string) => { const stream = await getStreamFactory(deps)({ streamId }) if (!stream) { - throw new Error(`Stream ${streamId} not found`) + throw new StreamNotFoundError(`Stream ${streamId} not found`) } await updateStreamFactory(deps)({ id: streamId, @@ -1238,7 +1239,9 @@ export const legacyGetStreamsFactory = if (visibility && visibility !== 'all') { if (!['private', 'public'].includes(visibility)) - throw new Error('Stream visibility should be either private, public or all') + throw new LogicError( + 'Stream visibility should be either private, public or all' + ) const isPublic = visibility === 'public' const publicFunc: Knex.QueryCallback = function () { this.where({ isPublic }) diff --git a/packages/server/modules/core/repositories/tokens.ts b/packages/server/modules/core/repositories/tokens.ts index f60df9828..a1f53581c 100644 --- a/packages/server/modules/core/repositories/tokens.ts +++ b/packages/server/modules/core/repositories/tokens.ts @@ -29,6 +29,7 @@ import { UserInputError } from '@/modules/core/errors/userinput' import { TokenResourceAccessRecord } from '@/modules/core/helpers/types' import { ServerScope } from '@speckle/shared' import { Knex } from 'knex' +import { TokenRevokationError } from '@/modules/core/errors/tokens' const tables = { apiTokens: (db: Knex) => db(ApiTokens.name), @@ -126,7 +127,7 @@ export const revokeTokenByIdFactory = .where({ id: tokenId.slice(0, 10) }) .del() - if (delCount === 0) throw new Error('Token revokation failed') + if (delCount === 0) throw new TokenRevokationError('Token revokation failed') return true } diff --git a/packages/server/modules/core/rest/diffDownload.ts b/packages/server/modules/core/rest/diffDownload.ts index a19072b23..6bc85da38 100644 --- a/packages/server/modules/core/rest/diffDownload.ts +++ b/packages/server/modules/core/rest/diffDownload.ts @@ -14,6 +14,7 @@ import { ensureError } from '@speckle/shared' import chain from 'stream-chain' import { get } from 'lodash' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' +import { DatabaseError } from '@/modules/shared/errors' const { FF_OBJECTS_STREAMING_FIX } = getFeatureFlags() @@ -154,7 +155,7 @@ export default (app: Application) => { } } catch (ex) { req.log.error(ex, `DB Error streaming objects`) - speckleObjStream.emit('error', new Error('Database streaming error')) + speckleObjStream.emit('error', new DatabaseError('Database streaming error')) } finally { speckleObjStream.end() } diff --git a/packages/server/modules/core/services/commit/management.ts b/packages/server/modules/core/services/commit/management.ts index 78ca57100..e70546244 100644 --- a/packages/server/modules/core/services/commit/management.ts +++ b/packages/server/modules/core/services/commit/management.ts @@ -34,6 +34,7 @@ import { import { CommitCreateError, CommitDeleteError, + CommitNotFoundError, CommitReceiveError, CommitUpdateError } from '@/modules/core/errors/commit' @@ -47,6 +48,7 @@ import { BranchRecord, CommitRecord } from '@/modules/core/helpers/types' import { EventBusEmit } from '@/modules/shared/services/eventBus' import { ensureError, Roles } from '@speckle/shared' import { has } from 'lodash' +import { BranchNotFoundError } from '@/modules/core/errors/branch' export const markCommitReceivedAndNotifyFactory = ({ getCommit, saveActivity }: { getCommit: GetCommit; saveActivity: SaveActivity }) => @@ -301,10 +303,10 @@ export const updateCommitAndNotifyFactory = const newBranch = await deps.getStreamBranchByName(streamId, newBranchName) if (!newBranch || !branch) { - throw new Error("Couldn't resolve branch") + throw new BranchNotFoundError("Couldn't resolve branch") } if (!commit) { - throw new Error("Couldn't find commit") + throw new CommitNotFoundError("Couldn't find commit") } await deps.switchCommitBranch(commitId, newBranch.id, branch.id) diff --git a/packages/server/modules/core/services/commit/retrieval.ts b/packages/server/modules/core/services/commit/retrieval.ts index db6a3027f..910b242d8 100644 --- a/packages/server/modules/core/services/commit/retrieval.ts +++ b/packages/server/modules/core/services/commit/retrieval.ts @@ -17,6 +17,7 @@ import { PaginatedBranchCommitsParams } from '@/modules/core/domain/commits/operations' import { GetStreamBranchByName } from '@/modules/core/domain/branches/operations' +import { BranchNotFoundError } from '@/modules/core/errors/branch' export const legacyGetPaginatedStreamCommitsFactory = (deps: { @@ -110,7 +111,8 @@ export const getBranchCommitsTotalCountByNameFactory = branchName = branchName.toLowerCase() const myBranch = await deps.getStreamBranchByName(streamId, branchName) - if (!myBranch) throw new Error(`Failed to find branch with name ${branchName}.`) + if (!myBranch) + throw new BranchNotFoundError(`Failed to find branch with name ${branchName}.`) return deps.getBranchCommitsTotalCount({ branchId: myBranch.id }) } @@ -123,7 +125,8 @@ export const getPaginatedBranchCommitsItemsByNameFactory = branchName = branchName.toLowerCase() const myBranch = await deps.getStreamBranchByName(streamId, branchName) - if (!myBranch) throw new Error(`Failed to find branch with name ${branchName}.`) + if (!myBranch) + throw new BranchNotFoundError(`Failed to find branch with name ${branchName}.`) return deps.getPaginatedBranchCommitsItems({ branchId: myBranch.id, limit, cursor }) } diff --git a/packages/server/modules/core/services/tokens.ts b/packages/server/modules/core/services/tokens.ts index a7f022610..720bb7775 100644 --- a/packages/server/modules/core/services/tokens.ts +++ b/packages/server/modules/core/services/tokens.ts @@ -23,6 +23,7 @@ import { } from '@/modules/core/domain/tokens/operations' import { GetTokenAppInfo } from '@/modules/auth/domain/operations' import { GetUserRole } from '@/modules/core/domain/users/operations' +import { TokenCreateError } from '@/modules/core/errors/user' /* Tokens @@ -50,7 +51,7 @@ export const createTokenFactory = async ({ userId, name, scopes, lifespan, limitResources }) => { const { tokenId, tokenString, tokenHash, lastChars } = await createBareToken() - if (scopes.length === 0) throw new Error('No scopes provided') + if (scopes.length === 0) throw new TokenCreateError('No scopes provided') const token = { id: tokenId, diff --git a/packages/server/modules/core/services/users/management.ts b/packages/server/modules/core/services/users/management.ts index 7a2c713a9..99b06e05d 100644 --- a/packages/server/modules/core/services/users/management.ts +++ b/packages/server/modules/core/services/users/management.ts @@ -16,7 +16,11 @@ import { UpdateUserServerRole, ValidateUserPassword } from '@/modules/core/domain/users/operations' -import { UserUpdateError, UserValidationError } from '@/modules/core/errors/user' +import { + UserCreateError, + UserUpdateError, + UserValidationError +} from '@/modules/core/errors/user' import { PasswordTooShortError, UserInputError } from '@/modules/core/errors/userinput' import { UserUpdateInput } from '@/modules/core/graph/generated/graphql' import type { UserRecord } from '@/modules/core/helpers/userHelper' @@ -203,7 +207,7 @@ export const createUserFactory = if (userEmail) throw new UserInputError('Email taken. Try logging in?') const newUser = await deps.storeUser({ user: finalUser }) - if (!newUser) throw new Error("Couldn't create user") + if (!newUser) throw new UserCreateError("Couldn't create user") const userRole = (await deps.countAdminUsers()) === 0 diff --git a/packages/server/modules/cross-server-sync/utils/graphqlClient.ts b/packages/server/modules/cross-server-sync/utils/graphqlClient.ts index 7d99e83aa..746b79acf 100644 --- a/packages/server/modules/cross-server-sync/utils/graphqlClient.ts +++ b/packages/server/modules/cross-server-sync/utils/graphqlClient.ts @@ -9,6 +9,7 @@ import { import { setContext } from '@apollo/client/link/context' import { getServerVersion } from '@/modules/shared/helpers/envHelper' import { CrossSyncClientTestQuery } from '@/modules/cross-server-sync/graph/generated/graphql' +import { EnvironmentResourceError } from '@/modules/shared/errors' export type GraphQLClient = ApolloClient @@ -23,7 +24,7 @@ export const assertValidGraphQLResult = ( operationName: string ) => { if (res.errors?.length) { - throw new Error( + throw new EnvironmentResourceError( `GQL operation '${operationName}' failed because of errors: ` + JSON.stringify(res.errors) ) @@ -67,7 +68,7 @@ export const createApolloClient = async ( assertValidGraphQLResult(res, 'Target server test query') if (!res.data?._) { - throw new Error( + throw new EnvironmentResourceError( "Couldn't construct working Apollo Client, test query failed cause of unexpected response: " + JSON.stringify(res.data) ) diff --git a/packages/server/modules/emails/utils/transporter.ts b/packages/server/modules/emails/utils/transporter.ts index e83e3116a..009c49d28 100644 --- a/packages/server/modules/emails/utils/transporter.ts +++ b/packages/server/modules/emails/utils/transporter.ts @@ -1,4 +1,5 @@ import { logger, moduleLogger } from '@/logging/logging' +import { MisconfiguredEnvironmentError } from '@/modules/shared/errors' import { isEmailEnabled, isTestEnv } from '@/modules/shared/helpers/envHelper' import { createTransport, Transporter } from 'nodemailer' @@ -37,7 +38,7 @@ export async function initializeTransporter(): Promise const message = '📧 Email provider is enabled but transport has not initialized correctly. Please review the email configuration or your email system for problems.' moduleLogger.error(message) - throw new Error(message) + throw new MisconfiguredEnvironmentError(message) } } @@ -47,7 +48,7 @@ export async function initializeTransporter(): Promise const message = '📧 In testing a mock email provider is enabled but transport has not initialized correctly.' moduleLogger.error(message) - throw new Error(message) + throw new MisconfiguredEnvironmentError(message) } } diff --git a/packages/server/modules/gatekeeper/clients/stripe.ts b/packages/server/modules/gatekeeper/clients/stripe.ts index d01123033..d4decae0f 100644 --- a/packages/server/modules/gatekeeper/clients/stripe.ts +++ b/packages/server/modules/gatekeeper/clients/stripe.ts @@ -9,6 +9,7 @@ import { WorkspacePlanBillingIntervals, WorkspacePricingPlans } from '@/modules/gatekeeper/domain/workspacePricing' +import { EnvironmentResourceError, LogicError } from '@/modules/shared/errors' import { Stripe } from 'stripe' type GetWorkspacePlanPrice = (args: { @@ -72,7 +73,8 @@ export const createCheckoutSessionFactory = cancel_url }) - if (!session.url) throw new Error('Failed to create an active checkout session') + if (!session.url) + throw new EnvironmentResourceError('Failed to create an active checkout session') return { id: session.id, url: session.url, @@ -148,7 +150,7 @@ export const parseSubscriptionData = ( : subscriptionItem.price.product.id const quantity = subscriptionItem.quantity if (!quantity) - throw new Error( + throw new LogicError( 'invalid subscription, we do not support products without quantities' ) return { diff --git a/packages/server/modules/gatekeeper/errors/license.ts b/packages/server/modules/gatekeeper/errors/license.ts new file mode 100644 index 000000000..94500f5c6 --- /dev/null +++ b/packages/server/modules/gatekeeper/errors/license.ts @@ -0,0 +1,7 @@ +import { BaseError } from '@/modules/shared/errors' + +export class InvalidLicenseError extends BaseError { + static defaultMessage = 'Invalid license' + static code = 'INVALID_LICENSE' + static statusCode = 400 +} diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index 55e3de94a..10ed138a2 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -34,6 +34,7 @@ import { upgradeWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/servic import { isWorkspaceReadOnlyFactory } from '@/modules/gatekeeper/services/readOnly' import { calculateSubscriptionSeats } from '@/modules/gatekeeper/domain/billing' import { WorkspacePaymentMethod } from '@/test/graphql/generated/graphql' +import { LogicError } from '@/modules/shared/errors' const { FF_GATEKEEPER_MODULE_ENABLED } = getFeatureFlags() @@ -93,7 +94,9 @@ export = FF_GATEKEEPER_MODULE_ENABLED if (!workspaceSubscription) return null const workspace = await getWorkspaceFactory({ db })({ workspaceId }) if (!workspace) - throw new Error('This cannot be, if there is a sub, there is a workspace') + throw new LogicError( + 'This cannot be, if there is a sub, there is a workspace' + ) return await createCustomerPortalUrlFactory({ stripe: getStripeClient(), frontendOrigin: getFrontendOrigin() diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index 49e2298d0..fe482ea37 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -41,6 +41,7 @@ import { renderEmail } from '@/modules/emails/services/emailRendering' import coreModule from '@/modules/core/index' import { isProjectReadOnlyFactory } from '@/modules/gatekeeper/services/readOnly' import { WorkspaceReadOnlyError } from '@/modules/gatekeeper/errors/billing' +import { InvalidLicenseError } from '@/modules/gatekeeper/errors/license' const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() @@ -170,7 +171,7 @@ const gatekeeperModule: SpeckleModule = { requiredModules: ['gatekeeper'] }) if (!isLicenseValid) - throw new Error( + throw new InvalidLicenseError( 'The gatekeeper module needs a valid license to run, contact Speckle to get one.' ) @@ -203,7 +204,7 @@ const gatekeeperModule: SpeckleModule = { requiredModules: ['billing'] }) if (!isLicenseValid) - throw new Error( + throw new InvalidLicenseError( 'The the billing module needs a valid license to run, contact Speckle to get one.' ) // TODO: create a cron job, that removes unused seats from the subscription at the beginning of each workspace plan's billing cycle diff --git a/packages/server/modules/gatekeeper/services/subscriptions.ts b/packages/server/modules/gatekeeper/services/subscriptions.ts index 3549ef318..ba1daf151 100644 --- a/packages/server/modules/gatekeeper/services/subscriptions.ts +++ b/packages/server/modules/gatekeeper/services/subscriptions.ts @@ -26,6 +26,7 @@ import { WorkspacePlanNotFoundError, WorkspaceSubscriptionNotFoundError } from '@/modules/gatekeeper/errors/billing' +import { LogicError } from '@/modules/shared/errors' import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/domain/operations' import { throwUncoveredError, WorkspaceRoles } from '@speckle/shared' import { cloneDeep, isEqual, sum } from 'lodash' @@ -206,7 +207,7 @@ const mutateSubscriptionDataWithNewValidSeatNumbers = ({ const product = subscriptionData.products.find( (product) => product.productId === productId ) - if (seatCount < 0) throw new Error('Invalid seat count, cannot be negative') + if (seatCount < 0) throw new LogicError('Invalid seat count, cannot be negative') if (seatCount === 0 && product === undefined) return if (seatCount === 0 && product !== undefined) { @@ -215,7 +216,7 @@ const mutateSubscriptionDataWithNewValidSeatNumbers = ({ } else if (product !== undefined && product.quantity >= seatCount) { product.quantity = seatCount } else { - throw new Error('Invalid subscription state') + throw new LogicError('Invalid subscription state') } } diff --git a/packages/server/modules/multiregion/utils/blobStorageSelector.ts b/packages/server/modules/multiregion/utils/blobStorageSelector.ts index 5f20f4d43..39fdfd1e7 100644 --- a/packages/server/modules/multiregion/utils/blobStorageSelector.ts +++ b/packages/server/modules/multiregion/utils/blobStorageSelector.ts @@ -44,7 +44,10 @@ export const initializeRegion = async (params: { // getAvailableRegionConfig allows getting configs that may not be registered yet const regionConfigs = await getAvailableRegionConfig() config = regionConfigs[regionKey].blobStorage - if (!config) throw new Error(`RegionKey ${regionKey} not available in config`) + if (!config) + throw new MisconfiguredEnvironmentError( + `RegionKey ${regionKey} not available in config` + ) } const storage = getObjectStorage({ diff --git a/packages/server/modules/multiregion/utils/dbSelector.ts b/packages/server/modules/multiregion/utils/dbSelector.ts index ae6b210e8..69d1f4e1f 100644 --- a/packages/server/modules/multiregion/utils/dbSelector.ts +++ b/packages/server/modules/multiregion/utils/dbSelector.ts @@ -6,7 +6,11 @@ import { } from '@/modules/multiregion/services/projectRegion' import { Knex } from 'knex' import { getRegionFactory } from '@/modules/multiregion/repositories' -import { DatabaseError, MisconfiguredEnvironmentError } from '@/modules/shared/errors' +import { + DatabaseError, + LogicError, + MisconfiguredEnvironmentError +} from '@/modules/shared/errors' import { configureClient } from '@/knexfile' import { InitializeRegion } from '@/modules/multiregion/domain/operations' import { @@ -37,12 +41,14 @@ export const getRegionDb: GetRegionDb = async ({ regionKey }) => { const regionClients = await getRegisteredRegionClients() if (!(regionKey in regionClients)) { const region = await getRegion({ key: regionKey }) - if (!region) throw new Error('Invalid region key') + if (!region) throw new LogicError('Invalid region key') // the region was initialized in a different server instance const regionConfigs = await getAvailableRegionConfig() if (!(regionKey in regionConfigs)) - throw new Error(`RegionKey ${regionKey} not available in config`) + throw new MisconfiguredEnvironmentError( + `RegionKey ${regionKey} not available in config` + ) const newRegionConfig = regionConfigs[regionKey] const regionDb = configureClient(newRegionConfig).public @@ -155,7 +161,9 @@ export const getAllRegisteredDbClients = async (): Promise< export const initializeRegion: InitializeRegion = async ({ regionKey }) => { const regionConfigs = await getAvailableRegionConfig() if (!(regionKey in regionConfigs)) - throw new Error(`RegionKey ${regionKey} not available in config`) + throw new MisconfiguredEnvironmentError( + `RegionKey ${regionKey} not available in config` + ) const newRegionConfig = regionConfigs[regionKey] const regionDb = configureClient(newRegionConfig) diff --git a/packages/server/modules/pwdreset/rest/index.ts b/packages/server/modules/pwdreset/rest/index.ts index dd37706d8..cda056419 100644 --- a/packages/server/modules/pwdreset/rest/index.ts +++ b/packages/server/modules/pwdreset/rest/index.ts @@ -16,7 +16,8 @@ import { } from '@/modules/pwdreset/repositories' import { finalizePasswordResetFactory } from '@/modules/pwdreset/services/finalize' import { requestPasswordRecoveryFactory } from '@/modules/pwdreset/services/request' -import { ensureError } from '@/modules/shared/helpers/errorHelper' +import { BadRequestError } from '@/modules/shared/errors' +import { ensureError } from '@speckle/shared' import { Express } from 'express' export default function (app: Express) { @@ -58,7 +59,8 @@ export default function (app: Express) { deleteExistingAuthTokens: deleteExistingAuthTokensFactory({ db }) }) - if (!req.body.tokenId || !req.body.password) throw new Error('Invalid request.') + if (!req.body.tokenId || !req.body.password) + throw new BadRequestError('Invalid request.') await finalizePasswordReset(req.body.tokenId, req.body.password) return res.status(200).send('Password reset. Please log in.') diff --git a/packages/server/modules/shared/authz.ts b/packages/server/modules/shared/authz.ts index a6786851d..ee823ecd0 100644 --- a/packages/server/modules/shared/authz.ts +++ b/packages/server/modules/shared/authz.ts @@ -294,7 +294,7 @@ export const authPipelineCreator = ( } // validate auth result a bit... if (authResult.authorized && authHasFailed(authResult)) - throw new Error('Auth failure') + throw new UnauthorizedError('Auth failure') return { context, authResult } } return pipeline @@ -337,7 +337,7 @@ export const throwForNotHavingServerRoleFactory = authResult: { authorized: false } }) if (authHasFailed(authResult)) - throw authResult.error ?? new Error('Auth failed without an error') + throw authResult.error ?? new ForbiddenError('Auth failed without an error') return true } diff --git a/packages/server/modules/shared/helpers/dbHelper.ts b/packages/server/modules/shared/helpers/dbHelper.ts index 5fa9e8659..f5c83f882 100644 --- a/packages/server/modules/shared/helpers/dbHelper.ts +++ b/packages/server/modules/shared/helpers/dbHelper.ts @@ -3,6 +3,7 @@ import { Knex } from 'knex' import { isString } from 'lodash' import { postgresMaxConnections } from '@/modules/shared/helpers/envHelper' +import { EnvironmentResourceError } from '@/modules/shared/errors' export type BatchedSelectOptions = { /** @@ -76,11 +77,11 @@ export const formatJsonArrayRecords = >( export const numberOfUsedOrPendingConnections = (db: Knex) => { if (!(db && 'client' in db && db.client)) - throw new Error('knex is not defined or does not have a client.') + throw new EnvironmentResourceError('knex is not defined or does not have a client.') const dbClient: Knex.Client = db.client if (!('pool' in dbClient && dbClient.pool)) - throw new Error('knex client does not have a connection pool') + throw new EnvironmentResourceError('knex client does not have a connection pool') const pool = dbClient.pool diff --git a/packages/server/modules/shared/helpers/sanitization.ts b/packages/server/modules/shared/helpers/sanitization.ts index c3bcf7935..002f12468 100644 --- a/packages/server/modules/shared/helpers/sanitization.ts +++ b/packages/server/modules/shared/helpers/sanitization.ts @@ -1,26 +1,33 @@ import { MaybeNullOrUndefined, Nullable } from '@speckle/shared' +import { BaseError } from '@/modules/shared/errors' const base64ImagePattern = /^data:image\/[a-zA-Z+.-]+;base64,[a-zA-Z0-9+/]+=*$/ +class InvalidUrlError extends BaseError { + static code = 'INVALID_URL_ERROR' + static defaultMessage = 'Invalid URL' + static statusCode = 400 +} + const validateImageUrl = (url: string): string => { // Parse the URL to ensure it's valid let parsedUrl: URL try { parsedUrl = new URL(url) } catch (e) { - throw new Error('Invalid URL') + throw new InvalidUrlError('Invalid URL') } // Only allow http: and https: protocols if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { - throw new Error('Invalid protocol') + throw new InvalidUrlError('Invalid protocol') } // Check the file extension to ensure it's an image const allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'] const extension = parsedUrl.pathname.split('.').pop()?.toLowerCase() || 'invalid' if (!allowedExtensions.includes(extension)) { - throw new Error('Invalid file type') + throw new InvalidUrlError('Invalid file type') } // If all checks pass, return the sanitized URL diff --git a/packages/server/modules/webhooks/errors/webhooks.ts b/packages/server/modules/webhooks/errors/webhooks.ts new file mode 100644 index 000000000..996966835 --- /dev/null +++ b/packages/server/modules/webhooks/errors/webhooks.ts @@ -0,0 +1,7 @@ +import { BaseError } from '@/modules/shared/errors' + +export class WebhookCreationError extends BaseError { + static defaultMessage = 'Error creating webhook' + static code = 'WEBHOOK_CREATION_ERROR' + static statusCode = 400 +} diff --git a/packages/server/modules/webhooks/services/webhooks.ts b/packages/server/modules/webhooks/services/webhooks.ts index eb471e771..50ade93b5 100644 --- a/packages/server/modules/webhooks/services/webhooks.ts +++ b/packages/server/modules/webhooks/services/webhooks.ts @@ -18,6 +18,7 @@ import { UserWithOptionalRole } from '@/modules/core/domain/users/types' import { GetUser } from '@/modules/core/domain/users/operations' import { GetServerInfo } from '@/modules/core/domain/server/operations' import { getServerOrigin } from '@/modules/shared/helpers/envHelper' +import { WebhookCreationError } from '@/modules/webhooks/errors/webhooks' const MAX_STREAM_WEBHOOKS = 100 @@ -40,7 +41,7 @@ export const createWebhookFactory = Partial>>) => { const streamWebhookCount = await countWebhooksByStreamId({ streamId }) if (streamWebhookCount >= MAX_STREAM_WEBHOOKS) { - throw new Error( + throw new WebhookCreationError( `Maximum number of webhooks for a stream reached (${MAX_STREAM_WEBHOOKS})` ) } diff --git a/packages/server/modules/workspaces/index.ts b/packages/server/modules/workspaces/index.ts index 7d13304e4..fb838a643 100644 --- a/packages/server/modules/workspaces/index.ts +++ b/packages/server/modules/workspaces/index.ts @@ -9,6 +9,7 @@ import { registerOrUpdateRole } from '@/modules/shared/repositories/roles' import { initializeEventListenersFactory } from '@/modules/workspaces/events/eventListener' import { validateModuleLicense } from '@/modules/gatekeeper/services/validateLicense' import { getSsoRouter } from '@/modules/workspaces/rest/sso' +import { InvalidLicenseError } from '@/modules/gatekeeper/errors/license' const { FF_WORKSPACES_MODULE_ENABLED, FF_WORKSPACES_SSO_ENABLED } = getFeatureFlags() @@ -32,7 +33,7 @@ const workspacesModule: SpeckleModule = { }) if (!isWorkspaceLicenseValid) - throw new Error( + throw new InvalidLicenseError( 'The workspaces module needs a valid license to run, contact Speckle to get one.' ) moduleLogger.info('⚒️ Init workspaces module')