From ef573086a54362adb9a4ffe3e31ca6eca77d8d04 Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Fri, 20 Feb 2026 21:23:45 +0100 Subject: [PATCH] fix(errors): shared error schema, sendError helper, correct 502/500 status codes (#66) Fastify's fast-json-stringify was silently stripping message and statusCode from error responses because most route schemas only declared { error: string }. The global error handler sends { error, message, statusCode } but those extra fields never reached clients. - Add shared errorResponseSchema and sendError() helper in api-errors.ts - Replace 16 per-route errorJsonSchema definitions with the shared import - Split PDS + DB try/catch blocks in topics, replies, reactions so PDS failures return 502 (Bad Gateway) and local DB failures return 500 - Change auth session and setup service errors from 502 to 500 (local ops) - Update tests to match new response shape and status codes --- src/lib/api-errors.ts | 53 ++++++++++++ src/routes/admin-settings.ts | 31 +++---- src/routes/admin-sybil.ts | 49 +++++------ src/routes/auth.ts | 13 ++- src/routes/block-mute.ts | 25 ++---- src/routes/categories.ts | 47 +++++------ src/routes/community-profiles.ts | 19 ++--- src/routes/global-filters.ts | 39 ++++----- src/routes/moderation-queue.ts | 23 ++---- src/routes/moderation.ts | 81 +++++++++--------- src/routes/notifications.ts | 19 ++--- src/routes/onboarding.ts | 46 +++++------ src/routes/profiles.ts | 31 +++---- src/routes/reactions.ts | 82 +++++++++++-------- src/routes/replies.ts | 136 ++++++++++++++++++------------- src/routes/search.ts | 11 +-- src/routes/setup.ts | 9 +- src/routes/topics.ts | 132 +++++++++++++++++------------- src/routes/uploads.ts | 17 ++-- tests/unit/routes/auth.test.ts | 36 ++++---- tests/unit/routes/setup.test.ts | 18 ++-- 21 files changed, 471 insertions(+), 446 deletions(-) diff --git a/src/lib/api-errors.ts b/src/lib/api-errors.ts index 68690cd..1228ec8 100644 --- a/src/lib/api-errors.ts +++ b/src/lib/api-errors.ts @@ -6,6 +6,59 @@ // code and a structured message for consistent API responses. // --------------------------------------------------------------------------- +import type { FastifyReply } from 'fastify' + +// --------------------------------------------------------------------------- +// Shared OpenAPI error response schema +// --------------------------------------------------------------------------- +// All route files should import this instead of defining their own. +// Matches the shape sent by the global error handler in app.ts and the +// sendError helper below: { error, message, statusCode }. +// --------------------------------------------------------------------------- + +export const errorResponseSchema = { + type: 'object' as const, + properties: { + error: { type: 'string' as const }, + message: { type: 'string' as const }, + statusCode: { type: 'integer' as const }, + }, +} + +// --------------------------------------------------------------------------- +// HTTP status text lookup +// --------------------------------------------------------------------------- + +const HTTP_STATUS_TEXTS: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 409: 'Conflict', + 429: 'Too Many Requests', + 500: 'Internal Server Error', + 502: 'Bad Gateway', +} + +// --------------------------------------------------------------------------- +// Structured error response helper +// --------------------------------------------------------------------------- + +/** + * Send a structured error response with consistent shape: { error, message, statusCode }. + * + * - `error` – HTTP status text (e.g. "Bad Gateway") + * - `message` – human-readable description of the failure + * - `statusCode` – numeric HTTP status code + */ +export function sendError(reply: FastifyReply, statusCode: number, message: string) { + return reply.status(statusCode).send({ + error: HTTP_STATUS_TEXTS[statusCode] ?? 'Error', + message, + statusCode, + }) +} + /** * Base API error with an HTTP status code. * Fastify uses `statusCode` on thrown errors to set the response status. diff --git a/src/routes/admin-settings.ts b/src/routes/admin-settings.ts index f9a8538..6359918 100644 --- a/src/routes/admin-settings.ts +++ b/src/routes/admin-settings.ts @@ -1,6 +1,6 @@ import { eq, sql } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' -import { notFound, badRequest } from '../lib/api-errors.js' +import { notFound, badRequest, errorResponseSchema } from '../lib/api-errors.js' import { isMaturityLowerThan } from '../lib/maturity.js' import { updateSettingsSchema } from '../validation/admin-settings.js' import { communitySettings } from '../db/schema/community-settings.js' @@ -32,15 +32,6 @@ const settingsJsonSchema = { }, } -const errorJsonSchema = { - type: 'object' as const, - properties: { - error: { type: 'string' as const }, - message: { type: 'string' as const }, - statusCode: { type: 'integer' as const }, - }, -} - const conflictJsonSchema = { type: 'object' as const, properties: { @@ -143,7 +134,7 @@ export function adminSettingsRoutes(): FastifyPluginCallback { communityLogoUrl: { type: ['string', 'null'] as const }, }, }, - 404: errorJsonSchema, + 404: errorResponseSchema, }, }, }, @@ -182,9 +173,9 @@ export function adminSettingsRoutes(): FastifyPluginCallback { security: [{ bearerAuth: [] }], response: { 200: settingsJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -242,10 +233,10 @@ export function adminSettingsRoutes(): FastifyPluginCallback { }, response: { 200: settingsJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, 409: conflictJsonSchema, }, }, @@ -400,8 +391,8 @@ export function adminSettingsRoutes(): FastifyPluginCallback { security: [{ bearerAuth: [] }], response: { 200: statsJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, }, }, }, diff --git a/src/routes/admin-sybil.ts b/src/routes/admin-sybil.ts index d875ba4..239407e 100644 --- a/src/routes/admin-sybil.ts +++ b/src/routes/admin-sybil.ts @@ -1,6 +1,6 @@ import { eq, and, desc, sql, count } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' -import { notFound, badRequest, tooManyRequests } from '../lib/api-errors.js' +import { notFound, badRequest, tooManyRequests, errorResponseSchema } from '../lib/api-errors.js' import { trustSeedCreateSchema, trustSeedQuerySchema, @@ -24,13 +24,6 @@ import { pdsTrustFactors } from '../db/schema/pds-trust-factors.js' // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- -const errorJsonSchema = { - type: 'object' as const, - properties: { - error: { type: 'string' as const }, - }, -} - const trustSeedJsonSchema = { type: 'object' as const, properties: { @@ -240,7 +233,7 @@ export function adminSybilRoutes(): FastifyPluginCallback { cursor: { type: ['string', 'null'] }, }, }, - 400: errorJsonSchema, + 400: errorResponseSchema, }, }, }, @@ -347,10 +340,10 @@ export function adminSybilRoutes(): FastifyPluginCallback { }, response: { 201: trustSeedJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -428,8 +421,8 @@ export function adminSybilRoutes(): FastifyPluginCallback { }, response: { 204: { type: 'null' as const }, - 400: errorJsonSchema, - 404: errorJsonSchema, + 400: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -493,7 +486,7 @@ export function adminSybilRoutes(): FastifyPluginCallback { cursor: { type: ['string', 'null'] }, }, }, - 400: errorJsonSchema, + 400: errorResponseSchema, }, }, }, @@ -593,8 +586,8 @@ export function adminSybilRoutes(): FastifyPluginCallback { }, }, }, - 400: errorJsonSchema, - 404: errorJsonSchema, + 400: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -674,10 +667,10 @@ export function adminSybilRoutes(): FastifyPluginCallback { }, response: { 200: sybilClusterJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -786,7 +779,7 @@ export function adminSybilRoutes(): FastifyPluginCallback { cursor: { type: ['string', 'null'] }, }, }, - 400: errorJsonSchema, + 400: errorResponseSchema, }, }, }, @@ -858,7 +851,7 @@ export function adminSybilRoutes(): FastifyPluginCallback { }, response: { 200: pdsTrustJsonSchema, - 400: errorJsonSchema, + 400: errorResponseSchema, }, }, }, @@ -924,7 +917,7 @@ export function adminSybilRoutes(): FastifyPluginCallback { startedAt: { type: 'string', format: 'date-time' }, }, }, - 429: errorJsonSchema, + 429: errorResponseSchema, }, }, }, @@ -1071,7 +1064,7 @@ export function adminSybilRoutes(): FastifyPluginCallback { cursor: { type: ['string', 'null'] }, }, }, - 400: errorJsonSchema, + 400: errorResponseSchema, }, }, }, @@ -1153,8 +1146,8 @@ export function adminSybilRoutes(): FastifyPluginCallback { }, response: { 200: behavioralFlagJsonSchema, - 400: errorJsonSchema, - 404: errorJsonSchema, + 400: errorResponseSchema, + 404: errorResponseSchema, }, }, }, diff --git a/src/routes/auth.ts b/src/routes/auth.ts index d46774a..4b9e0a6 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -2,6 +2,7 @@ import { z } from 'zod/v4' import { eq } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' import type { NodeOAuthClient } from '@atproto/oauth-client-node' +import { sendError } from '../lib/api-errors.js' import { BARAZO_BASE_SCOPES, BARAZO_CROSSPOST_SCOPES, @@ -93,7 +94,7 @@ export function authRoutes(oauthClient: NodeOAuthClient): FastifyPluginCallback return await reply.status(200).send({ url: redirectUrl.toString() }) } catch (err: unknown) { app.log.error({ err, handle }, 'OAuth authorize failed') - return await reply.status(502).send({ error: 'Failed to initiate login' }) + return sendError(reply, 502, 'Failed to initiate login') } } ) @@ -216,9 +217,7 @@ export function authRoutes(oauthClient: NodeOAuthClient): FastifyPluginCallback return await reply.status(200).send({ url: redirectUrl.toString() }) } catch (err: unknown) { app.log.error({ err, handle: session.handle }, 'Cross-post authorize failed') - return await reply - .status(502) - .send({ error: 'Failed to initiate cross-post authorization' }) + return sendError(reply, 502, 'Failed to initiate cross-post authorization') } } ) @@ -265,7 +264,7 @@ export function authRoutes(oauthClient: NodeOAuthClient): FastifyPluginCallback }) } catch (err: unknown) { app.log.error({ err }, 'Session refresh failed') - return reply.status(502).send({ error: 'Service temporarily unavailable' }) + return sendError(reply, 500, 'Service temporarily unavailable') } }) @@ -283,7 +282,7 @@ export function authRoutes(oauthClient: NodeOAuthClient): FastifyPluginCallback await sessionService.deleteSession(sid) } catch (err: unknown) { app.log.error({ err }, 'Session deletion failed') - return reply.status(502).send({ error: 'Service temporarily unavailable' }) + return sendError(reply, 500, 'Service temporarily unavailable') } // Clear the cookie @@ -323,7 +322,7 @@ export function authRoutes(oauthClient: NodeOAuthClient): FastifyPluginCallback }) } catch (err: unknown) { app.log.error({ err }, 'Token validation failed') - return reply.status(502).send({ error: 'Service temporarily unavailable' }) + return sendError(reply, 500, 'Service temporarily unavailable') } }) diff --git a/src/routes/block-mute.ts b/src/routes/block-mute.ts index e6d9acc..94e37bd 100644 --- a/src/routes/block-mute.ts +++ b/src/routes/block-mute.ts @@ -1,6 +1,6 @@ import { eq } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' -import { badRequest } from '../lib/api-errors.js' +import { badRequest, errorResponseSchema } from '../lib/api-errors.js' import { didParamSchema } from '../validation/block-mute.js' import { userPreferences } from '../db/schema/user-preferences.js' @@ -8,13 +8,6 @@ import { userPreferences } from '../db/schema/user-preferences.js' // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- -const errorJsonSchema = { - type: 'object' as const, - properties: { - error: { type: 'string' as const }, - }, -} - const successJsonSchema = { type: 'object' as const, properties: { @@ -61,8 +54,8 @@ export function blockMuteRoutes(): FastifyPluginCallback { params: didParamJsonSchema, response: { 200: successJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, }, }, }, @@ -132,8 +125,8 @@ export function blockMuteRoutes(): FastifyPluginCallback { params: didParamJsonSchema, response: { 200: successJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, }, }, }, @@ -197,8 +190,8 @@ export function blockMuteRoutes(): FastifyPluginCallback { params: didParamJsonSchema, response: { 200: successJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, }, }, }, @@ -268,8 +261,8 @@ export function blockMuteRoutes(): FastifyPluginCallback { params: didParamJsonSchema, response: { 200: successJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, }, }, }, diff --git a/src/routes/categories.ts b/src/routes/categories.ts index 4dccc68..196a40e 100644 --- a/src/routes/categories.ts +++ b/src/routes/categories.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto' import { eq, and, count } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' import { getCommunityDid } from '../config/env.js' -import { notFound, badRequest, conflict } from '../lib/api-errors.js' +import { notFound, badRequest, conflict, errorResponseSchema } from '../lib/api-errors.js' import { isMaturityLowerThan } from '../lib/maturity.js' import { createCategorySchema, @@ -157,15 +157,6 @@ const categoryWithTopicCountJsonSchema = { }, } -const errorJsonSchema = { - type: 'object' as const, - properties: { - error: { type: 'string' as const }, - message: { type: 'string' as const }, - statusCode: { type: 'integer' as const }, - }, -} - // --------------------------------------------------------------------------- // Category routes plugin // --------------------------------------------------------------------------- @@ -256,7 +247,7 @@ export function categoryRoutes(): FastifyPluginCallback { }, response: { 200: categoryWithTopicCountJsonSchema, - 404: errorJsonSchema, + 404: errorResponseSchema, }, }, }, @@ -315,10 +306,10 @@ export function categoryRoutes(): FastifyPluginCallback { }, response: { 201: categoryJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 409: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 409: errorResponseSchema, }, }, }, @@ -429,11 +420,11 @@ export function categoryRoutes(): FastifyPluginCallback { }, response: { 200: categoryJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 409: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + 409: errorResponseSchema, }, }, }, @@ -569,10 +560,10 @@ export function categoryRoutes(): FastifyPluginCallback { }, response: { 204: { type: 'null' }, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 409: errorJsonSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + 409: errorResponseSchema, }, }, }, @@ -650,10 +641,10 @@ export function categoryRoutes(): FastifyPluginCallback { }, response: { 200: categoryJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, }, }, }, diff --git a/src/routes/community-profiles.ts b/src/routes/community-profiles.ts index 198c9e6..bcc37dd 100644 --- a/src/routes/community-profiles.ts +++ b/src/routes/community-profiles.ts @@ -1,6 +1,6 @@ import { eq, and } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' -import { notFound, badRequest } from '../lib/api-errors.js' +import { notFound, badRequest, errorResponseSchema } from '../lib/api-errors.js' import { resolveProfile } from '../lib/resolve-profile.js' import type { SourceProfile, CommunityOverride } from '../lib/resolve-profile.js' import { updateCommunityProfileSchema } from '../validation/community-profiles.js' @@ -11,13 +11,6 @@ import { communityProfiles } from '../db/schema/community-profiles.js' // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- -const errorJsonSchema = { - type: 'object' as const, - properties: { - error: { type: 'string' as const }, - }, -} - const communityProfileJsonSchema = { type: 'object' as const, properties: { @@ -84,8 +77,8 @@ export function communityProfileRoutes(): FastifyPluginCallback { }, response: { 200: communityProfileJsonSchema, - 401: errorJsonSchema, - 404: errorJsonSchema, + 401: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -186,8 +179,8 @@ export function communityProfileRoutes(): FastifyPluginCallback { }, response: { 200: successJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, }, }, }, @@ -253,7 +246,7 @@ export function communityProfileRoutes(): FastifyPluginCallback { }, response: { 204: { type: 'null' }, - 401: errorJsonSchema, + 401: errorResponseSchema, }, }, }, diff --git a/src/routes/global-filters.ts b/src/routes/global-filters.ts index 9779e6f..e06e6a9 100644 --- a/src/routes/global-filters.ts +++ b/src/routes/global-filters.ts @@ -1,6 +1,6 @@ import { eq, and, desc, sql } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' -import { badRequest } from '../lib/api-errors.js' +import { badRequest, errorResponseSchema } from '../lib/api-errors.js' import { communityFilterQuerySchema, updateCommunityFilterSchema, @@ -15,13 +15,6 @@ import { accountFilters } from '../db/schema/account-filters.js' // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- -const errorJsonSchema = { - type: 'object' as const, - properties: { - error: { type: 'string' as const }, - }, -} - const communityFilterJsonSchema = { type: 'object' as const, properties: { @@ -147,9 +140,9 @@ export function globalFilterRoutes(): FastifyPluginCallback { cursor: { type: ['string', 'null'] }, }, }, - 400: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 400: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -231,9 +224,9 @@ export function globalFilterRoutes(): FastifyPluginCallback { }, response: { 200: communityFilterJsonSchema, - 400: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 400: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -316,9 +309,9 @@ export function globalFilterRoutes(): FastifyPluginCallback { cursor: { type: ['string', 'null'] }, }, }, - 400: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 400: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -403,9 +396,9 @@ export function globalFilterRoutes(): FastifyPluginCallback { }, response: { 200: accountFilterJsonSchema, - 400: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 400: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -491,9 +484,9 @@ export function globalFilterRoutes(): FastifyPluginCallback { }, }, }, - 400: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 400: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, }, }, }, diff --git a/src/routes/moderation-queue.ts b/src/routes/moderation-queue.ts index 86cf2d0..fcb9bde 100644 --- a/src/routes/moderation-queue.ts +++ b/src/routes/moderation-queue.ts @@ -1,7 +1,7 @@ import { eq, and, desc, sql } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' import { getCommunityDid } from '../config/env.js' -import { notFound, badRequest, conflict } from '../lib/api-errors.js' +import { notFound, badRequest, conflict, errorResponseSchema } from '../lib/api-errors.js' import { wordFilterSchema, queueActionSchema, queueQuerySchema } from '../validation/anti-spam.js' import { moderationQueue } from '../db/schema/moderation-queue.js' import { accountTrust } from '../db/schema/account-trust.js' @@ -14,13 +14,6 @@ import { createRequireModerator } from '../auth/require-moderator.js' // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- -const errorJsonSchema = { - type: 'object' as const, - properties: { - error: { type: 'string' as const }, - }, -} - const queueItemJsonSchema = { type: 'object' as const, properties: { @@ -124,7 +117,7 @@ export function moderationQueueRoutes(): FastifyPluginCallback { cursor: { type: ['string', 'null'] }, }, }, - 400: errorJsonSchema, + 400: errorResponseSchema, }, }, }, @@ -206,11 +199,11 @@ export function moderationQueueRoutes(): FastifyPluginCallback { }, response: { 200: queueItemJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 409: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + 409: errorResponseSchema, }, }, }, @@ -455,7 +448,7 @@ export function moderationQueueRoutes(): FastifyPluginCallback { }, }, }, - 400: errorJsonSchema, + 400: errorResponseSchema, }, }, }, diff --git a/src/routes/moderation.ts b/src/routes/moderation.ts index 0140b71..73e8c88 100644 --- a/src/routes/moderation.ts +++ b/src/routes/moderation.ts @@ -1,7 +1,13 @@ import { eq, and, desc, sql } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' import { getCommunityDid } from '../config/env.js' -import { notFound, forbidden, badRequest, conflict } from '../lib/api-errors.js' +import { + notFound, + forbidden, + badRequest, + conflict, + errorResponseSchema, +} from '../lib/api-errors.js' import { lockTopicSchema, pinTopicSchema, @@ -32,13 +38,6 @@ import { createNotificationService } from '../services/notification.js' // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- -const errorJsonSchema = { - type: 'object' as const, - properties: { - error: { type: 'string' as const }, - }, -} - const moderationActionJsonSchema = { type: 'object' as const, properties: { @@ -178,9 +177,9 @@ export function moderationRoutes(): FastifyPluginCallback { isLocked: { type: 'boolean' }, }, }, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -271,9 +270,9 @@ export function moderationRoutes(): FastifyPluginCallback { isPinned: { type: 'boolean' }, }, }, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -365,11 +364,11 @@ export function moderationRoutes(): FastifyPluginCallback { isModDeleted: { type: 'boolean' }, }, }, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 409: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + 409: errorResponseSchema, }, }, }, @@ -518,10 +517,10 @@ export function moderationRoutes(): FastifyPluginCallback { isBanned: { type: 'boolean' }, }, }, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -639,7 +638,7 @@ export function moderationRoutes(): FastifyPluginCallback { cursor: { type: ['string', 'null'] }, }, }, - 400: errorJsonSchema, + 400: errorResponseSchema, }, }, }, @@ -719,10 +718,10 @@ export function moderationRoutes(): FastifyPluginCallback { }, response: { 201: reportJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 404: errorJsonSchema, - 409: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 404: errorResponseSchema, + 409: errorResponseSchema, }, }, }, @@ -866,7 +865,7 @@ export function moderationRoutes(): FastifyPluginCallback { cursor: { type: ['string', 'null'] }, }, }, - 400: errorJsonSchema, + 400: errorResponseSchema, }, }, }, @@ -949,10 +948,10 @@ export function moderationRoutes(): FastifyPluginCallback { }, response: { 200: reportJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 404: errorJsonSchema, - 409: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 404: errorResponseSchema, + 409: errorResponseSchema, }, }, }, @@ -1176,7 +1175,7 @@ export function moderationRoutes(): FastifyPluginCallback { trustedPostThreshold: { type: 'number' }, }, }, - 400: errorJsonSchema, + 400: errorResponseSchema, }, }, }, @@ -1258,8 +1257,8 @@ export function moderationRoutes(): FastifyPluginCallback { cursor: { type: ['string', 'null'] }, }, }, - 400: errorJsonSchema, - 401: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, }, }, }, @@ -1343,11 +1342,11 @@ export function moderationRoutes(): FastifyPluginCallback { }, response: { 200: reportJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 409: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + 409: errorResponseSchema, }, }, }, diff --git a/src/routes/notifications.ts b/src/routes/notifications.ts index 2e213fe..ed06cad 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -1,6 +1,6 @@ import { eq, and, sql, desc } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' -import { badRequest } from '../lib/api-errors.js' +import { badRequest, errorResponseSchema } from '../lib/api-errors.js' import { notificationQuerySchema, markReadSchema } from '../validation/notifications.js' import { notifications } from '../db/schema/notifications.js' @@ -21,13 +21,6 @@ const notificationJsonSchema = { }, } -const errorJsonSchema = { - type: 'object' as const, - properties: { - error: { type: 'string' as const }, - }, -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -120,8 +113,8 @@ export function notificationRoutes(): FastifyPluginCallback { total: { type: 'number' }, }, }, - 400: errorJsonSchema, - 401: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, }, }, }, @@ -222,8 +215,8 @@ export function notificationRoutes(): FastifyPluginCallback { success: { type: 'boolean' }, }, }, - 400: errorJsonSchema, - 401: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, }, }, }, @@ -283,7 +276,7 @@ export function notificationRoutes(): FastifyPluginCallback { unread: { type: 'number' }, }, }, - 401: errorJsonSchema, + 401: errorResponseSchema, }, }, }, diff --git a/src/routes/onboarding.ts b/src/routes/onboarding.ts index 5af06be..326e745 100644 --- a/src/routes/onboarding.ts +++ b/src/routes/onboarding.ts @@ -1,7 +1,7 @@ import { eq, and, asc } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' import { getCommunityDid } from '../config/env.js' -import { notFound, badRequest, forbidden } from '../lib/api-errors.js' +import { notFound, badRequest, forbidden, errorResponseSchema } from '../lib/api-errors.js' import { createOnboardingFieldSchema, updateOnboardingFieldSchema, @@ -53,14 +53,6 @@ const onboardingStatusJsonSchema = { }, } -const errorJsonSchema = { - type: 'object' as const, - properties: { - error: { type: 'string' as const }, - message: { type: 'string' as const }, - }, -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -110,8 +102,8 @@ export function onboardingRoutes(): FastifyPluginCallback { type: 'array' as const, items: onboardingFieldJsonSchema, }, - 401: errorJsonSchema, - 403: errorJsonSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, }, }, }, @@ -154,9 +146,9 @@ export function onboardingRoutes(): FastifyPluginCallback { }, response: { 201: onboardingFieldJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, }, }, }, @@ -224,10 +216,10 @@ export function onboardingRoutes(): FastifyPluginCallback { }, response: { 200: onboardingFieldJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -299,9 +291,9 @@ export function onboardingRoutes(): FastifyPluginCallback { type: 'object' as const, properties: { success: { type: 'boolean' as const } }, }, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -364,9 +356,9 @@ export function onboardingRoutes(): FastifyPluginCallback { type: 'array' as const, items: onboardingFieldJsonSchema, }, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, }, }, }, @@ -420,7 +412,7 @@ export function onboardingRoutes(): FastifyPluginCallback { security: [{ bearerAuth: [] }], response: { 200: onboardingStatusJsonSchema, - 401: errorJsonSchema, + 401: errorResponseSchema, }, }, }, @@ -498,8 +490,8 @@ export function onboardingRoutes(): FastifyPluginCallback { complete: { type: 'boolean' as const }, }, }, - 400: errorJsonSchema, - 401: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, }, }, }, diff --git a/src/routes/profiles.ts b/src/routes/profiles.ts index fad2d62..676c4aa 100644 --- a/src/routes/profiles.ts +++ b/src/routes/profiles.ts @@ -1,6 +1,6 @@ import { eq, and, sql } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' -import { notFound, badRequest } from '../lib/api-errors.js' +import { notFound, badRequest, errorResponseSchema } from '../lib/api-errors.js' import { userPreferencesSchema, communityPreferencesSchema, @@ -25,13 +25,6 @@ import { pdsTrustFactors } from '../db/schema/pds-trust-factors.js' // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- -const errorJsonSchema = { - type: 'object' as const, - properties: { - error: { type: 'string' as const }, - }, -} - const profileJsonSchema = { type: 'object' as const, properties: { @@ -199,7 +192,7 @@ export function profileRoutes(): FastifyPluginCallback { }, response: { 200: profileJsonSchema, - 404: errorJsonSchema, + 404: errorResponseSchema, }, }, }, @@ -312,7 +305,7 @@ export function profileRoutes(): FastifyPluginCallback { }, response: { 200: reputationJsonSchema, - 404: errorJsonSchema, + 404: errorResponseSchema, }, }, }, @@ -503,8 +496,8 @@ export function profileRoutes(): FastifyPluginCallback { }, }, }, - 400: errorJsonSchema, - 401: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, }, }, }, @@ -562,7 +555,7 @@ export function profileRoutes(): FastifyPluginCallback { security: [{ bearerAuth: [] }], response: { 200: preferencesJsonSchema, - 401: errorJsonSchema, + 401: errorResponseSchema, }, }, }, @@ -629,8 +622,8 @@ export function profileRoutes(): FastifyPluginCallback { }, response: { 200: preferencesJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, }, }, }, @@ -725,7 +718,7 @@ export function profileRoutes(): FastifyPluginCallback { }, response: { 200: communityPrefsJsonSchema, - 401: errorJsonSchema, + 401: errorResponseSchema, }, }, }, @@ -815,8 +808,8 @@ export function profileRoutes(): FastifyPluginCallback { }, response: { 200: communityPrefsJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, }, }, }, @@ -908,7 +901,7 @@ export function profileRoutes(): FastifyPluginCallback { security: [{ bearerAuth: [] }], response: { 204: { type: 'null' }, - 401: errorJsonSchema, + 401: errorResponseSchema, }, }, }, diff --git a/src/routes/reactions.ts b/src/routes/reactions.ts index 990f090..ea5f4b0 100644 --- a/src/routes/reactions.ts +++ b/src/routes/reactions.ts @@ -2,7 +2,14 @@ import { eq, and, sql, asc } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' import { getCommunityDid } from '../config/env.js' import { createPdsClient } from '../lib/pds-client.js' -import { notFound, forbidden, badRequest, conflict } from '../lib/api-errors.js' +import { + notFound, + forbidden, + badRequest, + conflict, + errorResponseSchema, + sendError, +} from '../lib/api-errors.js' import { createReactionSchema, reactionQuerySchema } from '../validation/reactions.js' import { reactions } from '../db/schema/reactions.js' import { topics } from '../db/schema/topics.js' @@ -37,13 +44,6 @@ const reactionJsonSchema = { }, } -const errorJsonSchema = { - type: 'object' as const, - properties: { - error: { type: 'string' as const }, - }, -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -148,12 +148,13 @@ export function reactionRoutes(): FastifyPluginCallback { createdAt: { type: 'string', format: 'date-time' }, }, }, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 409: errorJsonSchema, - 502: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + 409: errorResponseSchema, + 500: errorResponseSchema, + 502: errorResponseSchema, }, }, }, @@ -231,11 +232,19 @@ export function reactionRoutes(): FastifyPluginCallback { createdAt: now, } + // Write record to user's PDS + let pdsResult: { uri: string; cid: string } try { - // Write record to user's PDS - const result = await pdsClient.createRecord(user.did, COLLECTION, record) - const rkey = extractRkey(result.uri) + pdsResult = await pdsClient.createRecord(user.did, COLLECTION, record) + } catch (err: unknown) { + if (err instanceof Error && 'statusCode' in err) throw err + app.log.error({ err, did: user.did }, 'PDS write failed for reaction creation') + return sendError(reply, 502, 'Failed to write to remote PDS') + } + const rkey = extractRkey(pdsResult.uri) + + try { // Track repo if this is user's first interaction const repoManager = firehose.getRepoManager() const alreadyTracked = await repoManager.isTracked(user.did) @@ -248,14 +257,14 @@ export function reactionRoutes(): FastifyPluginCallback { const inserted = await tx .insert(reactions) .values({ - uri: result.uri, + uri: pdsResult.uri, rkey, authorDid: user.did, subjectUri, subjectCid, type: reactionType, communityDid, - cid: result.cid, + cid: pdsResult.cid, createdAt: new Date(now), indexedAt: new Date(), }) @@ -308,19 +317,17 @@ export function reactionRoutes(): FastifyPluginCallback { } return await reply.status(201).send({ - uri: result.uri, - cid: result.cid, + uri: pdsResult.uri, + cid: pdsResult.cid, rkey, type: reactionType, subjectUri, createdAt: now, }) } catch (err: unknown) { - if (err instanceof Error && 'statusCode' in err) { - throw err // Re-throw ApiError instances - } + if (err instanceof Error && 'statusCode' in err) throw err app.log.error({ err, did: user.did }, 'Failed to create reaction') - return reply.status(502).send({ error: 'Failed to create reaction' }) + return sendError(reply, 500, 'Failed to save reaction locally') } } ) @@ -346,10 +353,11 @@ export function reactionRoutes(): FastifyPluginCallback { }, response: { 204: { type: 'null' }, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 502: errorJsonSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + 500: errorResponseSchema, + 502: errorResponseSchema, }, }, }, @@ -381,10 +389,16 @@ export function reactionRoutes(): FastifyPluginCallback { const rkey = extractRkey(decodedUri) + // Delete from PDS try { - // Delete from PDS await pdsClient.deleteRecord(user.did, COLLECTION, rkey) + } catch (err: unknown) { + if (err instanceof Error && 'statusCode' in err) throw err + app.log.error({ err, uri: decodedUri }, 'PDS delete failed for reaction') + return sendError(reply, 502, 'Failed to delete record from remote PDS') + } + try { // In transaction: delete from DB + decrement count on subject await db.transaction(async (tx) => { await tx @@ -412,11 +426,9 @@ export function reactionRoutes(): FastifyPluginCallback { return await reply.status(204).send() } catch (err: unknown) { - if (err instanceof Error && 'statusCode' in err) { - throw err - } + if (err instanceof Error && 'statusCode' in err) throw err app.log.error({ err, uri: decodedUri }, 'Failed to delete reaction') - return await reply.status(502).send({ error: 'Failed to delete reaction' }) + return sendError(reply, 500, 'Failed to delete reaction locally') } } ) @@ -450,7 +462,7 @@ export function reactionRoutes(): FastifyPluginCallback { cursor: { type: ['string', 'null'] }, }, }, - 400: errorJsonSchema, + 400: errorResponseSchema, }, }, }, diff --git a/src/routes/replies.ts b/src/routes/replies.ts index 2cc8b8b..7119176 100644 --- a/src/routes/replies.ts +++ b/src/routes/replies.ts @@ -2,7 +2,13 @@ import { eq, and, sql, asc, notInArray } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' import { getCommunityDid } from '../config/env.js' import { createPdsClient } from '../lib/pds-client.js' -import { notFound, forbidden, badRequest } from '../lib/api-errors.js' +import { + notFound, + forbidden, + badRequest, + errorResponseSchema, + sendError, +} from '../lib/api-errors.js' import { resolveMaxMaturity, maturityAllows } from '../lib/content-filter.js' import type { MaturityUser } from '../lib/content-filter.js' import { loadBlockMuteLists } from '../lib/block-mute.js' @@ -82,13 +88,6 @@ const replyJsonSchema = { }, } -const errorJsonSchema = { - type: 'object' as const, - properties: { - error: { type: 'string' as const }, - }, -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -219,11 +218,12 @@ export function replyRoutes(): FastifyPluginCallback { createdAt: { type: 'string', format: 'date-time' }, }, }, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 502: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + 500: errorResponseSchema, + 502: errorResponseSchema, }, }, }, @@ -331,11 +331,19 @@ export function replyRoutes(): FastifyPluginCallback { ...(labels ? { labels } : {}), } + // Write record to user's PDS + let pdsResult: { uri: string; cid: string } try { - // Write record to user's PDS - const result = await pdsClient.createRecord(user.did, COLLECTION, record) - const rkey = extractRkey(result.uri) + pdsResult = await pdsClient.createRecord(user.did, COLLECTION, record) + } catch (err: unknown) { + if (err instanceof Error && 'statusCode' in err) throw err + app.log.error({ err, did: user.did }, 'PDS write failed for reply creation') + return sendError(reply, 502, 'Failed to write to remote PDS') + } + + const rkey = extractRkey(pdsResult.uri) + try { // Track repo if this is user's first post const repoManager = firehose.getRepoManager() const alreadyTracked = await repoManager.isTracked(user.did) @@ -348,7 +356,7 @@ export function replyRoutes(): FastifyPluginCallback { await db .insert(replies) .values({ - uri: result.uri, + uri: pdsResult.uri, rkey, authorDid: user.did, content, @@ -357,7 +365,7 @@ export function replyRoutes(): FastifyPluginCallback { parentUri: parentRefUri, parentCid: parentRefCid, communityDid: topic.communityDid, - cid: result.cid, + cid: pdsResult.cid, labels: labels ?? null, reactionCount: 0, moderationStatus: contentModerationStatus, @@ -369,7 +377,7 @@ export function replyRoutes(): FastifyPluginCallback { set: { content, labels: labels ?? null, - cid: result.cid, + cid: pdsResult.cid, moderationStatus: contentModerationStatus, indexedAt: new Date(), }, @@ -378,7 +386,7 @@ export function replyRoutes(): FastifyPluginCallback { // Insert moderation queue entries if held if (spamResult.held) { const queueEntries = spamResult.reasons.map((r) => ({ - contentUri: result.uri, + contentUri: pdsResult.uri, contentType: 'reply' as const, authorDid: user.did, communityDid: topic.communityDid, @@ -389,7 +397,7 @@ export function replyRoutes(): FastifyPluginCallback { app.log.info( { - replyUri: result.uri, + replyUri: pdsResult.uri, reasons: spamResult.reasons.map((r) => r.reason), authorDid: user.did, }, @@ -413,32 +421,35 @@ export function replyRoutes(): FastifyPluginCallback { if (!spamResult.held) { notificationService .notifyOnReply({ - replyUri: result.uri, + replyUri: pdsResult.uri, actorDid: user.did, topicUri: decodedTopicUri, parentUri: parentRefUri, communityDid: topic.communityDid, }) .catch((err: unknown) => { - app.log.error({ err, replyUri: result.uri }, 'Reply notification failed') + app.log.error({ err, replyUri: pdsResult.uri }, 'Reply notification failed') }) notificationService .notifyOnMentions({ content, - subjectUri: result.uri, + subjectUri: pdsResult.uri, actorDid: user.did, communityDid: topic.communityDid, }) .catch((err: unknown) => { - app.log.error({ err, replyUri: result.uri }, 'Mention notification failed') + app.log.error({ err, replyUri: pdsResult.uri }, 'Mention notification failed') }) // Fire-and-forget: record interaction graph edges app.interactionGraphService .recordReply(user.did, topic.authorDid, topic.communityDid) .catch((err: unknown) => { - app.log.warn({ err, replyUri: result.uri }, 'Interaction graph recordReply failed') + app.log.warn( + { err, replyUri: pdsResult.uri }, + 'Interaction graph recordReply failed' + ) }) app.interactionGraphService @@ -452,19 +463,17 @@ export function replyRoutes(): FastifyPluginCallback { } return await reply.status(201).send({ - uri: result.uri, - cid: result.cid, + uri: pdsResult.uri, + cid: pdsResult.cid, rkey, content, moderationStatus: contentModerationStatus, createdAt: now, }) } catch (err: unknown) { - if (err instanceof Error && 'statusCode' in err) { - throw err // Re-throw ApiError instances - } + if (err instanceof Error && 'statusCode' in err) throw err app.log.error({ err, did: user.did }, 'Failed to create reply') - return reply.status(502).send({ error: 'Failed to create reply' }) + return sendError(reply, 500, 'Failed to save reply locally') } } ) @@ -503,8 +512,8 @@ export function replyRoutes(): FastifyPluginCallback { cursor: { type: ['string', 'null'] }, }, }, - 400: errorJsonSchema, - 404: errorJsonSchema, + 400: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -696,11 +705,12 @@ export function replyRoutes(): FastifyPluginCallback { }, response: { 200: replyJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 502: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + 500: errorResponseSchema, + 502: errorResponseSchema, }, }, }, @@ -747,13 +757,21 @@ export function replyRoutes(): FastifyPluginCallback { ...(resolvedLabels ? { labels: resolvedLabels } : {}), } + // Update record on user's PDS + let pdsResult: { uri: string; cid: string } try { - const result = await pdsClient.updateRecord(user.did, COLLECTION, rkey, updatedRecord) + pdsResult = await pdsClient.updateRecord(user.did, COLLECTION, rkey, updatedRecord) + } catch (err: unknown) { + if (err instanceof Error && 'statusCode' in err) throw err + app.log.error({ err, uri: decodedUri }, 'PDS update failed for reply') + return sendError(reply, 502, 'Failed to update record on remote PDS') + } + try { // Build DB update set const dbUpdates: Record = { content, - cid: result.cid, + cid: pdsResult.cid, indexedAt: new Date(), } if (labels !== undefined) dbUpdates.labels = labels @@ -771,11 +789,9 @@ export function replyRoutes(): FastifyPluginCallback { return await reply.status(200).send(serializeReply(updatedRow)) } catch (err: unknown) { - if (err instanceof Error && 'statusCode' in err) { - throw err // Re-throw ApiError instances - } + if (err instanceof Error && 'statusCode' in err) throw err app.log.error({ err, uri: decodedUri }, 'Failed to update reply') - return await reply.status(502).send({ error: 'Failed to update reply' }) + return sendError(reply, 500, 'Failed to save reply update locally') } } ) @@ -801,10 +817,11 @@ export function replyRoutes(): FastifyPluginCallback { }, response: { 204: { type: 'null' }, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 502: errorJsonSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + 500: errorResponseSchema, + 502: errorResponseSchema, }, }, }, @@ -840,14 +857,19 @@ export function replyRoutes(): FastifyPluginCallback { throw forbidden('Not authorized to delete this reply') } - try { - // Author: delete from PDS AND DB - // Moderator: delete from DB only (leave record on PDS) - if (isAuthor) { - const rkey = extractRkey(decodedUri) + // Author: delete from PDS; moderator: skip PDS deletion + if (isAuthor) { + const rkey = extractRkey(decodedUri) + try { await pdsClient.deleteRecord(user.did, COLLECTION, rkey) + } catch (err: unknown) { + if (err instanceof Error && 'statusCode' in err) throw err + app.log.error({ err, uri: decodedUri }, 'PDS delete failed for reply') + return sendError(reply, 502, 'Failed to delete record from remote PDS') } + } + try { // Soft-delete reply and update topic replyCount in a transaction await db.transaction(async (tx) => { await tx @@ -864,11 +886,9 @@ export function replyRoutes(): FastifyPluginCallback { return await reply.status(204).send() } catch (err: unknown) { - if (err instanceof Error && 'statusCode' in err) { - throw err - } + if (err instanceof Error && 'statusCode' in err) throw err app.log.error({ err, uri: decodedUri }, 'Failed to delete reply') - return await reply.status(502).send({ error: 'Failed to delete reply' }) + return sendError(reply, 500, 'Failed to delete reply locally') } } ) diff --git a/src/routes/search.ts b/src/routes/search.ts index fbbd082..0e752b0 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -1,7 +1,7 @@ import { sql } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' import { getCommunityDid } from '../config/env.js' -import { badRequest } from '../lib/api-errors.js' +import { badRequest, errorResponseSchema } from '../lib/api-errors.js' import { loadMutedWords, contentMatchesMutedWords } from '../lib/muted-words.js' import { createEmbeddingService } from '../services/embedding.js' import { searchQuerySchema } from '../validation/search.js' @@ -32,13 +32,6 @@ const searchResultJsonSchema = { }, } -const errorJsonSchema = { - type: 'object' as const, - properties: { - error: { type: 'string' as const }, - }, -} - // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -238,7 +231,7 @@ export function searchRoutes(): FastifyPluginCallback { }, }, }, - 400: errorJsonSchema, + 400: errorResponseSchema, }, }, }, diff --git a/src/routes/setup.ts b/src/routes/setup.ts index 8e9c293..566f29e 100644 --- a/src/routes/setup.ts +++ b/src/routes/setup.ts @@ -1,5 +1,6 @@ import { z } from 'zod/v4' import type { FastifyPluginCallback } from 'fastify' +import { sendError } from '../lib/api-errors.js' // --------------------------------------------------------------------------- // Zod schemas for request validation @@ -38,9 +39,7 @@ export function setupRoutes(): FastifyPluginCallback { return await reply.status(200).send(status) } catch (err: unknown) { app.log.error({ err }, 'Failed to get setup status') - return await reply.status(502).send({ - error: 'Service temporarily unavailable', - }) + return sendError(reply, 500, 'Service temporarily unavailable') } }) @@ -81,9 +80,7 @@ export function setupRoutes(): FastifyPluginCallback { return await reply.status(200).send(result) } catch (err: unknown) { app.log.error({ err }, 'Failed to initialize community') - return await reply.status(502).send({ - error: 'Service temporarily unavailable', - }) + return sendError(reply, 500, 'Service temporarily unavailable') } } ) diff --git a/src/routes/topics.ts b/src/routes/topics.ts index 5275dc2..54fe14f 100644 --- a/src/routes/topics.ts +++ b/src/routes/topics.ts @@ -2,7 +2,13 @@ import { eq, and, desc, sql, inArray, notInArray, isNotNull, ne, or } from 'driz import type { FastifyPluginCallback } from 'fastify' import { getCommunityDid } from '../config/env.js' import { createPdsClient } from '../lib/pds-client.js' -import { notFound, forbidden, badRequest } from '../lib/api-errors.js' +import { + notFound, + forbidden, + badRequest, + errorResponseSchema, + sendError, +} from '../lib/api-errors.js' import { resolveMaxMaturity, allowedRatings, maturityAllows } from '../lib/content-filter.js' import type { MaturityUser } from '../lib/content-filter.js' import { createTopicSchema, updateTopicSchema, topicQuerySchema } from '../validation/topics.js' @@ -84,13 +90,6 @@ const topicJsonSchema = { }, } -const errorJsonSchema = { - type: 'object' as const, - properties: { - error: { type: 'string' as const }, - }, -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -231,10 +230,11 @@ export function topicRoutes(): FastifyPluginCallback { createdAt: { type: 'string', format: 'date-time' }, }, }, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 502: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 500: errorResponseSchema, + 502: errorResponseSchema, }, }, }, @@ -354,11 +354,19 @@ export function topicRoutes(): FastifyPluginCallback { ...(labels ? { labels } : {}), } + // Write record to user's PDS + let pdsResult: { uri: string; cid: string } try { - // Write record to user's PDS - const result = await pdsClient.createRecord(user.did, COLLECTION, record) - const rkey = extractRkey(result.uri) + pdsResult = await pdsClient.createRecord(user.did, COLLECTION, record) + } catch (err: unknown) { + if (err instanceof Error && 'statusCode' in err) throw err + app.log.error({ err, did: user.did }, 'PDS write failed for topic creation') + return sendError(reply, 502, 'Failed to write to remote PDS') + } + + const rkey = extractRkey(pdsResult.uri) + try { // Track repo if this is user's first post const repoManager = firehose.getRepoManager() const alreadyTracked = await repoManager.isTracked(user.did) @@ -371,7 +379,7 @@ export function topicRoutes(): FastifyPluginCallback { await db .insert(topics) .values({ - uri: result.uri, + uri: pdsResult.uri, rkey, authorDid: user.did, title, @@ -380,7 +388,7 @@ export function topicRoutes(): FastifyPluginCallback { tags: tags ?? [], labels: labels ?? null, communityDid, - cid: result.cid, + cid: pdsResult.cid, replyCount: 0, reactionCount: 0, moderationStatus: contentModerationStatus, @@ -396,7 +404,7 @@ export function topicRoutes(): FastifyPluginCallback { category, tags: tags ?? [], labels: labels ?? null, - cid: result.cid, + cid: pdsResult.cid, moderationStatus: contentModerationStatus, indexedAt: new Date(), }, @@ -405,7 +413,7 @@ export function topicRoutes(): FastifyPluginCallback { // Insert moderation queue entries if held if (spamResult.held) { const queueEntries = spamResult.reasons.map((r) => ({ - contentUri: result.uri, + contentUri: pdsResult.uri, contentType: 'topic' as const, authorDid: user.did, communityDid, @@ -416,7 +424,7 @@ export function topicRoutes(): FastifyPluginCallback { app.log.info( { - topicUri: result.uri, + topicUri: pdsResult.uri, reasons: spamResult.reasons.map((r) => r.reason), authorDid: user.did, }, @@ -433,14 +441,14 @@ export function topicRoutes(): FastifyPluginCallback { crossPostService .crossPostTopic({ did: user.did, - topicUri: result.uri, + topicUri: pdsResult.uri, title, content, category, communityDid, }) .catch((err: unknown) => { - app.log.error({ err, topicUri: result.uri }, 'Cross-posting failed') + app.log.error({ err, topicUri: pdsResult.uri }, 'Cross-posting failed') }) } @@ -449,18 +457,18 @@ export function topicRoutes(): FastifyPluginCallback { notificationService .notifyOnMentions({ content, - subjectUri: result.uri, + subjectUri: pdsResult.uri, actorDid: user.did, communityDid, }) .catch((err: unknown) => { - app.log.error({ err, topicUri: result.uri }, 'Mention notification failed') + app.log.error({ err, topicUri: pdsResult.uri }, 'Mention notification failed') }) } return await reply.status(201).send({ - uri: result.uri, - cid: result.cid, + uri: pdsResult.uri, + cid: pdsResult.cid, rkey, title, category, @@ -468,8 +476,9 @@ export function topicRoutes(): FastifyPluginCallback { createdAt: now, }) } catch (err: unknown) { + if (err instanceof Error && 'statusCode' in err) throw err app.log.error({ err, did: user.did }, 'Failed to create topic') - return reply.status(502).send({ error: 'Failed to create topic' }) + return sendError(reply, 500, 'Failed to save topic locally') } } ) @@ -503,7 +512,7 @@ export function topicRoutes(): FastifyPluginCallback { cursor: { type: ['string', 'null'] }, }, }, - 400: errorJsonSchema, + 400: errorResponseSchema, }, }, }, @@ -761,8 +770,8 @@ export function topicRoutes(): FastifyPluginCallback { }, response: { 200: topicJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -829,8 +838,8 @@ export function topicRoutes(): FastifyPluginCallback { }, response: { 200: topicJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, }, }, }, @@ -932,11 +941,12 @@ export function topicRoutes(): FastifyPluginCallback { }, response: { 200: topicJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 502: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + 500: errorResponseSchema, + 502: errorResponseSchema, }, }, }, @@ -985,12 +995,20 @@ export function topicRoutes(): FastifyPluginCallback { ...(resolvedLabels ? { labels: resolvedLabels } : {}), } + // Update record on user's PDS + let pdsResult: { uri: string; cid: string } try { - const result = await pdsClient.updateRecord(user.did, COLLECTION, rkey, updatedRecord) + pdsResult = await pdsClient.updateRecord(user.did, COLLECTION, rkey, updatedRecord) + } catch (err: unknown) { + if (err instanceof Error && 'statusCode' in err) throw err + app.log.error({ err, uri: decodedUri }, 'PDS update failed for topic') + return sendError(reply, 502, 'Failed to update record on remote PDS') + } + try { // Build DB update set const dbUpdates: Record = { - cid: result.cid, + cid: pdsResult.cid, indexedAt: new Date(), } if (updates.title !== undefined) dbUpdates.title = updates.title @@ -1012,11 +1030,9 @@ export function topicRoutes(): FastifyPluginCallback { return await reply.status(200).send(serializeTopic(updatedRow)) } catch (err: unknown) { - if (err instanceof Error && 'statusCode' in err) { - throw err // Re-throw ApiError instances - } + if (err instanceof Error && 'statusCode' in err) throw err app.log.error({ err, uri: decodedUri }, 'Failed to update topic') - return await reply.status(502).send({ error: 'Failed to update topic' }) + return sendError(reply, 500, 'Failed to save topic update locally') } } ) @@ -1042,10 +1058,11 @@ export function topicRoutes(): FastifyPluginCallback { }, response: { 204: { type: 'null' }, - 401: errorJsonSchema, - 403: errorJsonSchema, - 404: errorJsonSchema, - 502: errorJsonSchema, + 401: errorResponseSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + 500: errorResponseSchema, + 502: errorResponseSchema, }, }, }, @@ -1081,14 +1098,19 @@ export function topicRoutes(): FastifyPluginCallback { throw forbidden('Not authorized to delete this topic') } - try { - // Author: delete from PDS AND DB - // Moderator: delete from DB only (leave record on PDS) - if (isAuthor) { - const rkey = extractRkey(decodedUri) + // Author: delete from PDS; moderator: skip PDS deletion + if (isAuthor) { + const rkey = extractRkey(decodedUri) + try { await pdsClient.deleteRecord(user.did, COLLECTION, rkey) + } catch (err: unknown) { + if (err instanceof Error && 'statusCode' in err) throw err + app.log.error({ err, uri: decodedUri }, 'PDS delete failed for topic') + return sendError(reply, 502, 'Failed to delete record from remote PDS') } + } + try { // Best-effort cross-post deletion (fire-and-forget) crossPostService.deleteCrossPosts(decodedUri, user.did).catch((err: unknown) => { app.log.warn({ err, topicUri: decodedUri }, 'Failed to delete cross-posts') @@ -1099,11 +1121,9 @@ export function topicRoutes(): FastifyPluginCallback { return await reply.status(204).send() } catch (err: unknown) { - if (err instanceof Error && 'statusCode' in err) { - throw err - } + if (err instanceof Error && 'statusCode' in err) throw err app.log.error({ err, uri: decodedUri }, 'Failed to delete topic') - return await reply.status(502).send({ error: 'Failed to delete topic' }) + return sendError(reply, 500, 'Failed to delete topic locally') } } ) diff --git a/src/routes/uploads.ts b/src/routes/uploads.ts index 5d49531..fa77ec3 100644 --- a/src/routes/uploads.ts +++ b/src/routes/uploads.ts @@ -1,6 +1,6 @@ import type { FastifyPluginCallback } from 'fastify' import sharp from 'sharp' -import { badRequest } from '../lib/api-errors.js' +import { badRequest, errorResponseSchema } from '../lib/api-errors.js' import { communityProfiles } from '../db/schema/community-profiles.js' const ALLOWED_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']) @@ -12,13 +12,6 @@ const BANNER_SIZE = { width: 1500, height: 500 } // OpenAPI JSON Schema definitions // --------------------------------------------------------------------------- -const errorJsonSchema = { - type: 'object' as const, - properties: { - error: { type: 'string' as const }, - }, -} - const uploadResponseJsonSchema = { type: 'object' as const, properties: { @@ -65,8 +58,8 @@ export function uploadRoutes(): FastifyPluginCallback { params: paramsJsonSchema, response: { 200: uploadResponseJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, }, }, }, @@ -130,8 +123,8 @@ export function uploadRoutes(): FastifyPluginCallback { params: paramsJsonSchema, response: { 200: uploadResponseJsonSchema, - 400: errorJsonSchema, - 401: errorJsonSchema, + 400: errorResponseSchema, + 401: errorResponseSchema, }, }, }, diff --git a/tests/unit/routes/auth.test.ts b/tests/unit/routes/auth.test.ts index 97a10ba..9b50cd1 100644 --- a/tests/unit/routes/auth.test.ts +++ b/tests/unit/routes/auth.test.ts @@ -232,8 +232,10 @@ describe('auth routes', () => { }) expect(response.statusCode).toBe(502) - const body = response.json<{ error: string }>() - expect(body.error).toBe('Failed to initiate login') + const body = response.json<{ error: string; message: string; statusCode: number }>() + expect(body.error).toBe('Bad Gateway') + expect(body.message).toBe('Failed to initiate login') + expect(body.statusCode).toBe(502) }) }) @@ -493,7 +495,7 @@ describe('auth routes', () => { expect(body.error).toBe('Invalid or expired token') }) - it('returns 502 when session service throws', async () => { + it('returns 500 when session service throws', async () => { validateAccessTokenFn.mockRejectedValueOnce(new Error('Valkey down')) const response = await app.inject({ @@ -504,9 +506,11 @@ describe('auth routes', () => { }, }) - expect(response.statusCode).toBe(502) - const body = response.json<{ error: string }>() - expect(body.error).toBe('Service temporarily unavailable') + expect(response.statusCode).toBe(500) + const body = response.json<{ error: string; message: string; statusCode: number }>() + expect(body.error).toBe('Internal Server Error') + expect(body.message).toBe('Service temporarily unavailable') + expect(body.statusCode).toBe(500) }) }) @@ -515,7 +519,7 @@ describe('auth routes', () => { // ========================================================================= describe('service error handling', () => { - it('returns 502 when refresh service throws', async () => { + it('returns 500 when refresh service throws', async () => { refreshSessionFn.mockRejectedValueOnce(new Error('Valkey down')) const response = await app.inject({ @@ -524,12 +528,14 @@ describe('auth routes', () => { cookies: { barazo_refresh: TEST_SID }, }) - expect(response.statusCode).toBe(502) - const body = response.json<{ error: string }>() - expect(body.error).toBe('Service temporarily unavailable') + expect(response.statusCode).toBe(500) + const body = response.json<{ error: string; message: string; statusCode: number }>() + expect(body.error).toBe('Internal Server Error') + expect(body.message).toBe('Service temporarily unavailable') + expect(body.statusCode).toBe(500) }) - it('returns 502 when delete service throws', async () => { + it('returns 500 when delete service throws', async () => { deleteSessionFn.mockRejectedValueOnce(new Error('Valkey down')) const response = await app.inject({ @@ -538,9 +544,11 @@ describe('auth routes', () => { cookies: { barazo_refresh: TEST_SID }, }) - expect(response.statusCode).toBe(502) - const body = response.json<{ error: string }>() - expect(body.error).toBe('Service temporarily unavailable') + expect(response.statusCode).toBe(500) + const body = response.json<{ error: string; message: string; statusCode: number }>() + expect(body.error).toBe('Internal Server Error') + expect(body.message).toBe('Service temporarily unavailable') + expect(body.statusCode).toBe(500) }) }) diff --git a/tests/unit/routes/setup.test.ts b/tests/unit/routes/setup.test.ts index 5730aca..8563182 100644 --- a/tests/unit/routes/setup.test.ts +++ b/tests/unit/routes/setup.test.ts @@ -145,7 +145,7 @@ describe('setup routes', () => { }) }) - it('returns 502 when service throws', async () => { + it('returns 500 when service throws', async () => { getStatusFn.mockRejectedValueOnce(new Error('DB down')) const response = await app.inject({ @@ -153,8 +153,11 @@ describe('setup routes', () => { url: '/api/setup/status', }) - expect(response.statusCode).toBe(502) - expect(response.json<{ error: string }>().error).toBe('Service temporarily unavailable') + expect(response.statusCode).toBe(500) + const body = response.json<{ error: string; message: string; statusCode: number }>() + expect(body.error).toBe('Internal Server Error') + expect(body.message).toBe('Service temporarily unavailable') + expect(body.statusCode).toBe(500) }) }) @@ -376,7 +379,7 @@ describe('setup routes', () => { expect(response.json<{ error: string }>().error).toBe('Invalid request body') }) - it('returns 502 when service throws', async () => { + it('returns 500 when service throws', async () => { validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()) initializeFn.mockRejectedValueOnce(new Error('DB down')) @@ -389,8 +392,11 @@ describe('setup routes', () => { payload: {}, }) - expect(response.statusCode).toBe(502) - expect(response.json<{ error: string }>().error).toBe('Service temporarily unavailable') + expect(response.statusCode).toBe(500) + const body = response.json<{ error: string; message: string; statusCode: number }>() + expect(body.error).toBe('Internal Server Error') + expect(body.message).toBe('Service temporarily unavailable') + expect(body.statusCode).toBe(500) }) }) }) -- 2.51.2