From 95cefec5c245ef0b95f49b822639fce917ce8b00 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Tue, 11 Aug 2026 20:31:42 +0200 Subject: [PATCH] feat: add parsable error handling to api methods --- nuxt.config.ts | 3 +- server/api/account/checkout.post.ts | 10 +---- server/api/account/discord-link.get.ts | 5 +-- server/api/account/discord-unlink.post.ts | 5 +-- server/api/account/tiers.get.ts | 5 +-- server/api/auth/forgot-password.post.ts | 5 +-- server/api/auth/login.post.ts | 14 ++----- server/utils/errors.ts | 18 +++++++++ server/utils/hcaptcha.ts | 2 +- shared/errors.ts | 47 +++++++++++++++++++++++ src/composables/errors.ts | 10 +++++ src/plugins/types.d.ts | 41 ++++++++++++++++++++ 12 files changed, 128 insertions(+), 37 deletions(-) create mode 100644 server/utils/errors.ts create mode 100644 shared/errors.ts create mode 100644 src/composables/errors.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index a7cd6c5..317394a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -34,7 +34,6 @@ export default defineNuxtConfig({ githubApiToken: '', stripeSecretKey: '', stripeNotificationEmail: '', - hcaptchaSiteKey: '', hcaptchaSecretKey: '', grpcHost: '', grpcApiKey: '', @@ -57,7 +56,7 @@ export default defineNuxtConfig({ public: { baseUrl: 'https://pretendo.network', apiBase: 'https://api.pretendo.cc', - hCaptchaSitekey: '', + hcaptchaSiteKey: '', cookieSecure: false } }, diff --git a/server/api/account/checkout.post.ts b/server/api/account/checkout.post.ts index 9dd28d2..24fcffc 100644 --- a/server/api/account/checkout.post.ts +++ b/server/api/account/checkout.post.ts @@ -8,10 +8,7 @@ export default defineEventHandler(async (event): Promise const stripe = useStripe(event); const config = useRuntimeConfig(event); if (!stripe || !papr) { - throw createError({ - status: 400, - message: 'Stripe integration not configured' - }); + throw createApiError('INTEGRATION_DISABLED'); } const body = await readZodBody(event, CheckoutSchema); @@ -30,10 +27,7 @@ export default defineEventHandler(async (event): Promise // ensure PNID always has latest customer ID if (auth.accessLevel >= 2) { - throw createError({ - status: 400, - message: 'Staff members do not need to purchase tiers' - }); + throw createApiError('STAFF_NO_DONATE'); } await papr.Pnid.updateOne({ pid: auth.pid }, { $set: { diff --git a/server/api/account/discord-link.get.ts b/server/api/account/discord-link.get.ts index 0b303a4..29cdab2 100644 --- a/server/api/account/discord-link.get.ts +++ b/server/api/account/discord-link.get.ts @@ -5,10 +5,7 @@ export default defineEventHandler(async (event): Promise enforceLoggedIn(event); const discord = useDiscord(event); if (!discord) { - throw createError({ - status: 400, - message: 'Discord integration not configured' - }); + throw createApiError('INTEGRATION_DISABLED'); } const redirectUrl = discord.makeCallbackUrl(); diff --git a/server/api/account/discord-unlink.post.ts b/server/api/account/discord-unlink.post.ts index 10edd3b..e37d59a 100644 --- a/server/api/account/discord-unlink.post.ts +++ b/server/api/account/discord-unlink.post.ts @@ -4,10 +4,7 @@ export default defineEventHandler(async (event): Promise => { const auth = enforceLoggedIn(event); const discord = useDiscord(event); if (!discord) { - throw createError({ - status: 400, - message: 'Discord integration not configured' - }); + throw createApiError('INTEGRATION_DISABLED'); } const grpc = useApiGrpcWithToken(event, auth.token); diff --git a/server/api/account/tiers.get.ts b/server/api/account/tiers.get.ts index b849334..130b0d5 100644 --- a/server/api/account/tiers.get.ts +++ b/server/api/account/tiers.get.ts @@ -4,10 +4,7 @@ export default defineEventHandler(async (event): Promise => { enforceLoggedIn(event); const stripe = useStripe(event); if (!stripe) { - throw createError({ - status: 400, - message: 'Stripe integration not configured' - }); + throw createApiError('INTEGRATION_DISABLED'); } const prices = await stripe.prices.list().autoPagingToArray({ limit: 10 }); diff --git a/server/api/auth/forgot-password.post.ts b/server/api/auth/forgot-password.post.ts index a60f9fe..1b5eb9d 100644 --- a/server/api/auth/forgot-password.post.ts +++ b/server/api/auth/forgot-password.post.ts @@ -7,10 +7,7 @@ export default defineEventHandler(async (event): Promise => { const captchaResult = await hcaptchaVerify(event, body.captchaResponse); if (!captchaResult) { - throw createError({ - status: 400, - message: 'Invalid captcha' - }); + throw createApiError('INVALID_CAPTCHA'); } await grpc.forgotPassword({ diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts index 79cbbab..fc74aa0 100644 --- a/server/api/auth/login.post.ts +++ b/server/api/auth/login.post.ts @@ -1,4 +1,4 @@ -import { ServerError } from 'nice-grpc'; +import { ClientError } from 'nice-grpc'; import { LoginSchema } from '#shared/api-types'; import type { ApiAuthLogin } from '#shared/api-types'; @@ -18,18 +18,12 @@ export default defineEventHandler(async (event): Promise => { refreshToken: res.refreshToken }; } catch (error: unknown) { - if (error instanceof ServerError) { + if (error instanceof ClientError) { if (error.details === 'INVALID_ARGUMENT: User not found') { - throw createError({ - status: 400, - message: 'User not found' - }); + throw createApiError('INVALID_USERNAME'); } if (error.details === 'INVALID_ARGUMENT: Password is incorrect') { - throw createError({ - status: 400, - message: 'Password was incorrect' - }); + throw createApiError('INVALID_PASSWORD'); } } throw error; diff --git a/server/utils/errors.ts b/server/utils/errors.ts new file mode 100644 index 0000000..e1afae2 --- /dev/null +++ b/server/utils/errors.ts @@ -0,0 +1,18 @@ +import { apiErrorCodeStatus, getTextForApiErrorCode } from '~~/shared/errors'; +import type { ApiErrorCodes, ApiError } from '~~/shared/errors'; + +export function createApiError(code: ApiErrorCodes) { + const err = createApiErrorBase(code); + return createError({ + statusCode: apiErrorCodeStatus[err.code], + message: err.message, + data: err + }); +} + +export function createApiErrorBase(code: ApiErrorCodes): ApiError { + return { + code, + message: getTextForApiErrorCode(code) + }; +} diff --git a/server/utils/hcaptcha.ts b/server/utils/hcaptcha.ts index dda18a9..155066e 100644 --- a/server/utils/hcaptcha.ts +++ b/server/utils/hcaptcha.ts @@ -13,7 +13,7 @@ export async function hcaptchaVerify(event: H3Event, captchaResponse: string | n if (!captchaResponse) { return false; } // No captcha filled in, invalid - const captchaVerify = await hcaptcha.verify(config.hcaptchaSiteKey, captchaResponse, undefined, config.hcaptchaSiteKey); + const captchaVerify = await hcaptcha.verify(config.hcaptchaSecretKey, captchaResponse, undefined, config.public.hcaptchaSiteKey); if (!captchaVerify.success) { return false; diff --git a/shared/errors.ts b/shared/errors.ts new file mode 100644 index 0000000..fb2d527 --- /dev/null +++ b/shared/errors.ts @@ -0,0 +1,47 @@ +const apiErrorCodes = { + UNPARSABLE_ERROR: 'Fatal exception!', + UNHANDLED_ERROR: 'Something went wrong', + INVALID_INPUT: 'Invalid input', + INTEGRATION_DISABLED: 'Integration with this service is disabled', + STAFF_NO_DONATE: 'Staff members do not need to purchase tiers', + INVALID_CAPTCHA: 'Invalid captcha, try again', + INVALID_USERNAME: 'Could not find user', + INVALID_PASSWORD: 'Incorrect password' +} as const; + +export type ApiErrorCodes = keyof typeof apiErrorCodes; + +export const apiErrorCodeStatus: Record = { + UNPARSABLE_ERROR: 500, + UNHANDLED_ERROR: 500, + INVALID_INPUT: 400, + INTEGRATION_DISABLED: 500, + STAFF_NO_DONATE: 400, + INVALID_CAPTCHA: 400, + INVALID_USERNAME: 400, + INVALID_PASSWORD: 400 +}; + +export function getTextForApiErrorCode(code: ApiErrorCodes): string { + return apiErrorCodes[code]; +} + +export type ApiError = { + code: ApiErrorCodes; + message: string; +}; + +export function getApiError(error: any): ApiError { + if (error?.code) { + return error as ApiError; + } + + if (error?.data?.code) { + return error.data as ApiError; + } + + return { + code: 'UNPARSABLE_ERROR', + message: getTextForApiErrorCode('UNPARSABLE_ERROR') + }; +} diff --git a/src/composables/errors.ts b/src/composables/errors.ts new file mode 100644 index 0000000..1c2b7e8 --- /dev/null +++ b/src/composables/errors.ts @@ -0,0 +1,10 @@ +import { FetchError } from 'ofetch'; +import { getApiError as baseGetApiError } from '~~/shared/errors'; +import type { ApiError } from '~~/shared/errors'; + +export function getApiError(error: any): ApiError { + if (error instanceof FetchError) { + return baseGetApiError(error.data); + } + return baseGetApiError(error); +} diff --git a/src/plugins/types.d.ts b/src/plugins/types.d.ts index 688acc5..9f6b68e 100644 --- a/src/plugins/types.d.ts +++ b/src/plugins/types.d.ts @@ -4,4 +4,45 @@ declare module '#app' { } } +declare module 'nuxt/schema' { + interface RuntimeConfig { + githubApiToken: string; + + stripeSecretKey: string; + stripeNotificationEmail: string; + + hcaptchaSecretKey: string; + + grpcHost: string; + grpcApiKey: string; + + mongoConnectionString: string; + + smtpHost: string; + smtpPort: number; + smtpUser: string; + smtpPassword: string; + smtpSecure: true; + smtpFromEmail: string; + smtpFromName: string; + + discordBotToken: string; + discordClientId: string; + discordClientSecret: string; + discordGuildId: string; + discordTesterRoleId: string; + discordSupporterRoleId: string; + + discourseSsoSecret: string; + } + + interface PublicRuntimeConfig { + baseUrl: string; + apiBase: string; + cookieSecure: boolean; + + hcaptchaSiteKey: string; + } +} + export { };