Barazo AppView backend barazo.forum

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

authored by

Guido X Jansen and committed by
GitHub
ef573086 9ea51e7d

+471 -446
+53
src/lib/api-errors.ts
··· 6 6 // code and a structured message for consistent API responses. 7 7 // --------------------------------------------------------------------------- 8 8 9 + import type { FastifyReply } from 'fastify' 10 + 11 + // --------------------------------------------------------------------------- 12 + // Shared OpenAPI error response schema 13 + // --------------------------------------------------------------------------- 14 + // All route files should import this instead of defining their own. 15 + // Matches the shape sent by the global error handler in app.ts and the 16 + // sendError helper below: { error, message, statusCode }. 17 + // --------------------------------------------------------------------------- 18 + 19 + export const errorResponseSchema = { 20 + type: 'object' as const, 21 + properties: { 22 + error: { type: 'string' as const }, 23 + message: { type: 'string' as const }, 24 + statusCode: { type: 'integer' as const }, 25 + }, 26 + } 27 + 28 + // --------------------------------------------------------------------------- 29 + // HTTP status text lookup 30 + // --------------------------------------------------------------------------- 31 + 32 + const HTTP_STATUS_TEXTS: Record<number, string> = { 33 + 400: 'Bad Request', 34 + 401: 'Unauthorized', 35 + 403: 'Forbidden', 36 + 404: 'Not Found', 37 + 409: 'Conflict', 38 + 429: 'Too Many Requests', 39 + 500: 'Internal Server Error', 40 + 502: 'Bad Gateway', 41 + } 42 + 43 + // --------------------------------------------------------------------------- 44 + // Structured error response helper 45 + // --------------------------------------------------------------------------- 46 + 47 + /** 48 + * Send a structured error response with consistent shape: { error, message, statusCode }. 49 + * 50 + * - `error` – HTTP status text (e.g. "Bad Gateway") 51 + * - `message` – human-readable description of the failure 52 + * - `statusCode` – numeric HTTP status code 53 + */ 54 + export function sendError(reply: FastifyReply, statusCode: number, message: string) { 55 + return reply.status(statusCode).send({ 56 + error: HTTP_STATUS_TEXTS[statusCode] ?? 'Error', 57 + message, 58 + statusCode, 59 + }) 60 + } 61 + 9 62 /** 10 63 * Base API error with an HTTP status code. 11 64 * Fastify uses `statusCode` on thrown errors to set the response status.
+11 -20
src/routes/admin-settings.ts
··· 1 1 import { eq, sql } from 'drizzle-orm' 2 2 import type { FastifyPluginCallback } from 'fastify' 3 - import { notFound, badRequest } from '../lib/api-errors.js' 3 + import { notFound, badRequest, errorResponseSchema } from '../lib/api-errors.js' 4 4 import { isMaturityLowerThan } from '../lib/maturity.js' 5 5 import { updateSettingsSchema } from '../validation/admin-settings.js' 6 6 import { communitySettings } from '../db/schema/community-settings.js' ··· 29 29 requireLoginForMature: { type: 'boolean' as const }, 30 30 createdAt: { type: 'string' as const, format: 'date-time' as const }, 31 31 updatedAt: { type: 'string' as const, format: 'date-time' as const }, 32 - }, 33 - } 34 - 35 - const errorJsonSchema = { 36 - type: 'object' as const, 37 - properties: { 38 - error: { type: 'string' as const }, 39 - message: { type: 'string' as const }, 40 - statusCode: { type: 'integer' as const }, 41 32 }, 42 33 } 43 34 ··· 143 134 communityLogoUrl: { type: ['string', 'null'] as const }, 144 135 }, 145 136 }, 146 - 404: errorJsonSchema, 137 + 404: errorResponseSchema, 147 138 }, 148 139 }, 149 140 }, ··· 182 173 security: [{ bearerAuth: [] }], 183 174 response: { 184 175 200: settingsJsonSchema, 185 - 401: errorJsonSchema, 186 - 403: errorJsonSchema, 187 - 404: errorJsonSchema, 176 + 401: errorResponseSchema, 177 + 403: errorResponseSchema, 178 + 404: errorResponseSchema, 188 179 }, 189 180 }, 190 181 }, ··· 242 233 }, 243 234 response: { 244 235 200: settingsJsonSchema, 245 - 400: errorJsonSchema, 246 - 401: errorJsonSchema, 247 - 403: errorJsonSchema, 248 - 404: errorJsonSchema, 236 + 400: errorResponseSchema, 237 + 401: errorResponseSchema, 238 + 403: errorResponseSchema, 239 + 404: errorResponseSchema, 249 240 409: conflictJsonSchema, 250 241 }, 251 242 }, ··· 400 391 security: [{ bearerAuth: [] }], 401 392 response: { 402 393 200: statsJsonSchema, 403 - 401: errorJsonSchema, 404 - 403: errorJsonSchema, 394 + 401: errorResponseSchema, 395 + 403: errorResponseSchema, 405 396 }, 406 397 }, 407 398 },
+21 -28
src/routes/admin-sybil.ts
··· 1 1 import { eq, and, desc, sql, count } from 'drizzle-orm' 2 2 import type { FastifyPluginCallback } from 'fastify' 3 - import { notFound, badRequest, tooManyRequests } from '../lib/api-errors.js' 3 + import { notFound, badRequest, tooManyRequests, errorResponseSchema } from '../lib/api-errors.js' 4 4 import { 5 5 trustSeedCreateSchema, 6 6 trustSeedQuerySchema, ··· 23 23 // --------------------------------------------------------------------------- 24 24 // OpenAPI JSON Schema definitions 25 25 // --------------------------------------------------------------------------- 26 - 27 - const errorJsonSchema = { 28 - type: 'object' as const, 29 - properties: { 30 - error: { type: 'string' as const }, 31 - }, 32 - } 33 26 34 27 const trustSeedJsonSchema = { 35 28 type: 'object' as const, ··· 240 233 cursor: { type: ['string', 'null'] }, 241 234 }, 242 235 }, 243 - 400: errorJsonSchema, 236 + 400: errorResponseSchema, 244 237 }, 245 238 }, 246 239 }, ··· 347 340 }, 348 341 response: { 349 342 201: trustSeedJsonSchema, 350 - 400: errorJsonSchema, 351 - 401: errorJsonSchema, 352 - 403: errorJsonSchema, 353 - 404: errorJsonSchema, 343 + 400: errorResponseSchema, 344 + 401: errorResponseSchema, 345 + 403: errorResponseSchema, 346 + 404: errorResponseSchema, 354 347 }, 355 348 }, 356 349 }, ··· 428 421 }, 429 422 response: { 430 423 204: { type: 'null' as const }, 431 - 400: errorJsonSchema, 432 - 404: errorJsonSchema, 424 + 400: errorResponseSchema, 425 + 404: errorResponseSchema, 433 426 }, 434 427 }, 435 428 }, ··· 493 486 cursor: { type: ['string', 'null'] }, 494 487 }, 495 488 }, 496 - 400: errorJsonSchema, 489 + 400: errorResponseSchema, 497 490 }, 498 491 }, 499 492 }, ··· 593 586 }, 594 587 }, 595 588 }, 596 - 400: errorJsonSchema, 597 - 404: errorJsonSchema, 589 + 400: errorResponseSchema, 590 + 404: errorResponseSchema, 598 591 }, 599 592 }, 600 593 }, ··· 674 667 }, 675 668 response: { 676 669 200: sybilClusterJsonSchema, 677 - 400: errorJsonSchema, 678 - 401: errorJsonSchema, 679 - 403: errorJsonSchema, 680 - 404: errorJsonSchema, 670 + 400: errorResponseSchema, 671 + 401: errorResponseSchema, 672 + 403: errorResponseSchema, 673 + 404: errorResponseSchema, 681 674 }, 682 675 }, 683 676 }, ··· 786 779 cursor: { type: ['string', 'null'] }, 787 780 }, 788 781 }, 789 - 400: errorJsonSchema, 782 + 400: errorResponseSchema, 790 783 }, 791 784 }, 792 785 }, ··· 858 851 }, 859 852 response: { 860 853 200: pdsTrustJsonSchema, 861 - 400: errorJsonSchema, 854 + 400: errorResponseSchema, 862 855 }, 863 856 }, 864 857 }, ··· 924 917 startedAt: { type: 'string', format: 'date-time' }, 925 918 }, 926 919 }, 927 - 429: errorJsonSchema, 920 + 429: errorResponseSchema, 928 921 }, 929 922 }, 930 923 }, ··· 1071 1064 cursor: { type: ['string', 'null'] }, 1072 1065 }, 1073 1066 }, 1074 - 400: errorJsonSchema, 1067 + 400: errorResponseSchema, 1075 1068 }, 1076 1069 }, 1077 1070 }, ··· 1153 1146 }, 1154 1147 response: { 1155 1148 200: behavioralFlagJsonSchema, 1156 - 400: errorJsonSchema, 1157 - 404: errorJsonSchema, 1149 + 400: errorResponseSchema, 1150 + 404: errorResponseSchema, 1158 1151 }, 1159 1152 }, 1160 1153 },
+6 -7
src/routes/auth.ts
··· 2 2 import { eq } from 'drizzle-orm' 3 3 import type { FastifyPluginCallback } from 'fastify' 4 4 import type { NodeOAuthClient } from '@atproto/oauth-client-node' 5 + import { sendError } from '../lib/api-errors.js' 5 6 import { 6 7 BARAZO_BASE_SCOPES, 7 8 BARAZO_CROSSPOST_SCOPES, ··· 93 94 return await reply.status(200).send({ url: redirectUrl.toString() }) 94 95 } catch (err: unknown) { 95 96 app.log.error({ err, handle }, 'OAuth authorize failed') 96 - return await reply.status(502).send({ error: 'Failed to initiate login' }) 97 + return sendError(reply, 502, 'Failed to initiate login') 97 98 } 98 99 } 99 100 ) ··· 216 217 return await reply.status(200).send({ url: redirectUrl.toString() }) 217 218 } catch (err: unknown) { 218 219 app.log.error({ err, handle: session.handle }, 'Cross-post authorize failed') 219 - return await reply 220 - .status(502) 221 - .send({ error: 'Failed to initiate cross-post authorization' }) 220 + return sendError(reply, 502, 'Failed to initiate cross-post authorization') 222 221 } 223 222 } 224 223 ) ··· 265 264 }) 266 265 } catch (err: unknown) { 267 266 app.log.error({ err }, 'Session refresh failed') 268 - return reply.status(502).send({ error: 'Service temporarily unavailable' }) 267 + return sendError(reply, 500, 'Service temporarily unavailable') 269 268 } 270 269 }) 271 270 ··· 283 282 await sessionService.deleteSession(sid) 284 283 } catch (err: unknown) { 285 284 app.log.error({ err }, 'Session deletion failed') 286 - return reply.status(502).send({ error: 'Service temporarily unavailable' }) 285 + return sendError(reply, 500, 'Service temporarily unavailable') 287 286 } 288 287 289 288 // Clear the cookie ··· 323 322 }) 324 323 } catch (err: unknown) { 325 324 app.log.error({ err }, 'Token validation failed') 326 - return reply.status(502).send({ error: 'Service temporarily unavailable' }) 325 + return sendError(reply, 500, 'Service temporarily unavailable') 327 326 } 328 327 }) 329 328
+9 -16
src/routes/block-mute.ts
··· 1 1 import { eq } from 'drizzle-orm' 2 2 import type { FastifyPluginCallback } from 'fastify' 3 - import { badRequest } from '../lib/api-errors.js' 3 + import { badRequest, errorResponseSchema } from '../lib/api-errors.js' 4 4 import { didParamSchema } from '../validation/block-mute.js' 5 5 import { userPreferences } from '../db/schema/user-preferences.js' 6 6 7 7 // --------------------------------------------------------------------------- 8 8 // OpenAPI JSON Schema definitions 9 9 // --------------------------------------------------------------------------- 10 - 11 - const errorJsonSchema = { 12 - type: 'object' as const, 13 - properties: { 14 - error: { type: 'string' as const }, 15 - }, 16 - } 17 10 18 11 const successJsonSchema = { 19 12 type: 'object' as const, ··· 61 54 params: didParamJsonSchema, 62 55 response: { 63 56 200: successJsonSchema, 64 - 400: errorJsonSchema, 65 - 401: errorJsonSchema, 57 + 400: errorResponseSchema, 58 + 401: errorResponseSchema, 66 59 }, 67 60 }, 68 61 }, ··· 132 125 params: didParamJsonSchema, 133 126 response: { 134 127 200: successJsonSchema, 135 - 400: errorJsonSchema, 136 - 401: errorJsonSchema, 128 + 400: errorResponseSchema, 129 + 401: errorResponseSchema, 137 130 }, 138 131 }, 139 132 }, ··· 197 190 params: didParamJsonSchema, 198 191 response: { 199 192 200: successJsonSchema, 200 - 400: errorJsonSchema, 201 - 401: errorJsonSchema, 193 + 400: errorResponseSchema, 194 + 401: errorResponseSchema, 202 195 }, 203 196 }, 204 197 }, ··· 268 261 params: didParamJsonSchema, 269 262 response: { 270 263 200: successJsonSchema, 271 - 400: errorJsonSchema, 272 - 401: errorJsonSchema, 264 + 400: errorResponseSchema, 265 + 401: errorResponseSchema, 273 266 }, 274 267 }, 275 268 },
+19 -28
src/routes/categories.ts
··· 2 2 import { eq, and, count } from 'drizzle-orm' 3 3 import type { FastifyPluginCallback } from 'fastify' 4 4 import { getCommunityDid } from '../config/env.js' 5 - import { notFound, badRequest, conflict } from '../lib/api-errors.js' 5 + import { notFound, badRequest, conflict, errorResponseSchema } from '../lib/api-errors.js' 6 6 import { isMaturityLowerThan } from '../lib/maturity.js' 7 7 import { 8 8 createCategorySchema, ··· 157 157 }, 158 158 } 159 159 160 - const errorJsonSchema = { 161 - type: 'object' as const, 162 - properties: { 163 - error: { type: 'string' as const }, 164 - message: { type: 'string' as const }, 165 - statusCode: { type: 'integer' as const }, 166 - }, 167 - } 168 - 169 160 // --------------------------------------------------------------------------- 170 161 // Category routes plugin 171 162 // --------------------------------------------------------------------------- ··· 256 247 }, 257 248 response: { 258 249 200: categoryWithTopicCountJsonSchema, 259 - 404: errorJsonSchema, 250 + 404: errorResponseSchema, 260 251 }, 261 252 }, 262 253 }, ··· 315 306 }, 316 307 response: { 317 308 201: categoryJsonSchema, 318 - 400: errorJsonSchema, 319 - 401: errorJsonSchema, 320 - 403: errorJsonSchema, 321 - 409: errorJsonSchema, 309 + 400: errorResponseSchema, 310 + 401: errorResponseSchema, 311 + 403: errorResponseSchema, 312 + 409: errorResponseSchema, 322 313 }, 323 314 }, 324 315 }, ··· 429 420 }, 430 421 response: { 431 422 200: categoryJsonSchema, 432 - 400: errorJsonSchema, 433 - 401: errorJsonSchema, 434 - 403: errorJsonSchema, 435 - 404: errorJsonSchema, 436 - 409: errorJsonSchema, 423 + 400: errorResponseSchema, 424 + 401: errorResponseSchema, 425 + 403: errorResponseSchema, 426 + 404: errorResponseSchema, 427 + 409: errorResponseSchema, 437 428 }, 438 429 }, 439 430 }, ··· 569 560 }, 570 561 response: { 571 562 204: { type: 'null' }, 572 - 401: errorJsonSchema, 573 - 403: errorJsonSchema, 574 - 404: errorJsonSchema, 575 - 409: errorJsonSchema, 563 + 401: errorResponseSchema, 564 + 403: errorResponseSchema, 565 + 404: errorResponseSchema, 566 + 409: errorResponseSchema, 576 567 }, 577 568 }, 578 569 }, ··· 650 641 }, 651 642 response: { 652 643 200: categoryJsonSchema, 653 - 400: errorJsonSchema, 654 - 401: errorJsonSchema, 655 - 403: errorJsonSchema, 656 - 404: errorJsonSchema, 644 + 400: errorResponseSchema, 645 + 401: errorResponseSchema, 646 + 403: errorResponseSchema, 647 + 404: errorResponseSchema, 657 648 }, 658 649 }, 659 650 },
+6 -13
src/routes/community-profiles.ts
··· 1 1 import { eq, and } from 'drizzle-orm' 2 2 import type { FastifyPluginCallback } from 'fastify' 3 - import { notFound, badRequest } from '../lib/api-errors.js' 3 + import { notFound, badRequest, errorResponseSchema } from '../lib/api-errors.js' 4 4 import { resolveProfile } from '../lib/resolve-profile.js' 5 5 import type { SourceProfile, CommunityOverride } from '../lib/resolve-profile.js' 6 6 import { updateCommunityProfileSchema } from '../validation/community-profiles.js' ··· 10 10 // --------------------------------------------------------------------------- 11 11 // OpenAPI JSON Schema definitions 12 12 // --------------------------------------------------------------------------- 13 - 14 - const errorJsonSchema = { 15 - type: 'object' as const, 16 - properties: { 17 - error: { type: 'string' as const }, 18 - }, 19 - } 20 13 21 14 const communityProfileJsonSchema = { 22 15 type: 'object' as const, ··· 84 77 }, 85 78 response: { 86 79 200: communityProfileJsonSchema, 87 - 401: errorJsonSchema, 88 - 404: errorJsonSchema, 80 + 401: errorResponseSchema, 81 + 404: errorResponseSchema, 89 82 }, 90 83 }, 91 84 }, ··· 186 179 }, 187 180 response: { 188 181 200: successJsonSchema, 189 - 400: errorJsonSchema, 190 - 401: errorJsonSchema, 182 + 400: errorResponseSchema, 183 + 401: errorResponseSchema, 191 184 }, 192 185 }, 193 186 }, ··· 253 246 }, 254 247 response: { 255 248 204: { type: 'null' }, 256 - 401: errorJsonSchema, 249 + 401: errorResponseSchema, 257 250 }, 258 251 }, 259 252 },
+16 -23
src/routes/global-filters.ts
··· 1 1 import { eq, and, desc, sql } from 'drizzle-orm' 2 2 import type { FastifyPluginCallback } from 'fastify' 3 - import { badRequest } from '../lib/api-errors.js' 3 + import { badRequest, errorResponseSchema } from '../lib/api-errors.js' 4 4 import { 5 5 communityFilterQuerySchema, 6 6 updateCommunityFilterSchema, ··· 14 14 // --------------------------------------------------------------------------- 15 15 // OpenAPI JSON Schema definitions 16 16 // --------------------------------------------------------------------------- 17 - 18 - const errorJsonSchema = { 19 - type: 'object' as const, 20 - properties: { 21 - error: { type: 'string' as const }, 22 - }, 23 - } 24 17 25 18 const communityFilterJsonSchema = { 26 19 type: 'object' as const, ··· 147 140 cursor: { type: ['string', 'null'] }, 148 141 }, 149 142 }, 150 - 400: errorJsonSchema, 151 - 403: errorJsonSchema, 152 - 404: errorJsonSchema, 143 + 400: errorResponseSchema, 144 + 403: errorResponseSchema, 145 + 404: errorResponseSchema, 153 146 }, 154 147 }, 155 148 }, ··· 231 224 }, 232 225 response: { 233 226 200: communityFilterJsonSchema, 234 - 400: errorJsonSchema, 235 - 403: errorJsonSchema, 236 - 404: errorJsonSchema, 227 + 400: errorResponseSchema, 228 + 403: errorResponseSchema, 229 + 404: errorResponseSchema, 237 230 }, 238 231 }, 239 232 }, ··· 316 309 cursor: { type: ['string', 'null'] }, 317 310 }, 318 311 }, 319 - 400: errorJsonSchema, 320 - 403: errorJsonSchema, 321 - 404: errorJsonSchema, 312 + 400: errorResponseSchema, 313 + 403: errorResponseSchema, 314 + 404: errorResponseSchema, 322 315 }, 323 316 }, 324 317 }, ··· 403 396 }, 404 397 response: { 405 398 200: accountFilterJsonSchema, 406 - 400: errorJsonSchema, 407 - 403: errorJsonSchema, 408 - 404: errorJsonSchema, 399 + 400: errorResponseSchema, 400 + 403: errorResponseSchema, 401 + 404: errorResponseSchema, 409 402 }, 410 403 }, 411 404 }, ··· 491 484 }, 492 485 }, 493 486 }, 494 - 400: errorJsonSchema, 495 - 403: errorJsonSchema, 496 - 404: errorJsonSchema, 487 + 400: errorResponseSchema, 488 + 403: errorResponseSchema, 489 + 404: errorResponseSchema, 497 490 }, 498 491 }, 499 492 },
+8 -15
src/routes/moderation-queue.ts
··· 1 1 import { eq, and, desc, sql } from 'drizzle-orm' 2 2 import type { FastifyPluginCallback } from 'fastify' 3 3 import { getCommunityDid } from '../config/env.js' 4 - import { notFound, badRequest, conflict } from '../lib/api-errors.js' 4 + import { notFound, badRequest, conflict, errorResponseSchema } from '../lib/api-errors.js' 5 5 import { wordFilterSchema, queueActionSchema, queueQuerySchema } from '../validation/anti-spam.js' 6 6 import { moderationQueue } from '../db/schema/moderation-queue.js' 7 7 import { accountTrust } from '../db/schema/account-trust.js' ··· 13 13 // --------------------------------------------------------------------------- 14 14 // OpenAPI JSON Schema definitions 15 15 // --------------------------------------------------------------------------- 16 - 17 - const errorJsonSchema = { 18 - type: 'object' as const, 19 - properties: { 20 - error: { type: 'string' as const }, 21 - }, 22 - } 23 16 24 17 const queueItemJsonSchema = { 25 18 type: 'object' as const, ··· 124 117 cursor: { type: ['string', 'null'] }, 125 118 }, 126 119 }, 127 - 400: errorJsonSchema, 120 + 400: errorResponseSchema, 128 121 }, 129 122 }, 130 123 }, ··· 206 199 }, 207 200 response: { 208 201 200: queueItemJsonSchema, 209 - 400: errorJsonSchema, 210 - 401: errorJsonSchema, 211 - 403: errorJsonSchema, 212 - 404: errorJsonSchema, 213 - 409: errorJsonSchema, 202 + 400: errorResponseSchema, 203 + 401: errorResponseSchema, 204 + 403: errorResponseSchema, 205 + 404: errorResponseSchema, 206 + 409: errorResponseSchema, 214 207 }, 215 208 }, 216 209 }, ··· 455 448 }, 456 449 }, 457 450 }, 458 - 400: errorJsonSchema, 451 + 400: errorResponseSchema, 459 452 }, 460 453 }, 461 454 },
+40 -41
src/routes/moderation.ts
··· 1 1 import { eq, and, desc, sql } from 'drizzle-orm' 2 2 import type { FastifyPluginCallback } from 'fastify' 3 3 import { getCommunityDid } from '../config/env.js' 4 - import { notFound, forbidden, badRequest, conflict } from '../lib/api-errors.js' 4 + import { 5 + notFound, 6 + forbidden, 7 + badRequest, 8 + conflict, 9 + errorResponseSchema, 10 + } from '../lib/api-errors.js' 5 11 import { 6 12 lockTopicSchema, 7 13 pinTopicSchema, ··· 31 37 // --------------------------------------------------------------------------- 32 38 // OpenAPI JSON Schema definitions 33 39 // --------------------------------------------------------------------------- 34 - 35 - const errorJsonSchema = { 36 - type: 'object' as const, 37 - properties: { 38 - error: { type: 'string' as const }, 39 - }, 40 - } 41 40 42 41 const moderationActionJsonSchema = { 43 42 type: 'object' as const, ··· 178 177 isLocked: { type: 'boolean' }, 179 178 }, 180 179 }, 181 - 401: errorJsonSchema, 182 - 403: errorJsonSchema, 183 - 404: errorJsonSchema, 180 + 401: errorResponseSchema, 181 + 403: errorResponseSchema, 182 + 404: errorResponseSchema, 184 183 }, 185 184 }, 186 185 }, ··· 271 270 isPinned: { type: 'boolean' }, 272 271 }, 273 272 }, 274 - 401: errorJsonSchema, 275 - 403: errorJsonSchema, 276 - 404: errorJsonSchema, 273 + 401: errorResponseSchema, 274 + 403: errorResponseSchema, 275 + 404: errorResponseSchema, 277 276 }, 278 277 }, 279 278 }, ··· 365 364 isModDeleted: { type: 'boolean' }, 366 365 }, 367 366 }, 368 - 400: errorJsonSchema, 369 - 401: errorJsonSchema, 370 - 403: errorJsonSchema, 371 - 404: errorJsonSchema, 372 - 409: errorJsonSchema, 367 + 400: errorResponseSchema, 368 + 401: errorResponseSchema, 369 + 403: errorResponseSchema, 370 + 404: errorResponseSchema, 371 + 409: errorResponseSchema, 373 372 }, 374 373 }, 375 374 }, ··· 518 517 isBanned: { type: 'boolean' }, 519 518 }, 520 519 }, 521 - 400: errorJsonSchema, 522 - 401: errorJsonSchema, 523 - 403: errorJsonSchema, 524 - 404: errorJsonSchema, 520 + 400: errorResponseSchema, 521 + 401: errorResponseSchema, 522 + 403: errorResponseSchema, 523 + 404: errorResponseSchema, 525 524 }, 526 525 }, 527 526 }, ··· 639 638 cursor: { type: ['string', 'null'] }, 640 639 }, 641 640 }, 642 - 400: errorJsonSchema, 641 + 400: errorResponseSchema, 643 642 }, 644 643 }, 645 644 }, ··· 719 718 }, 720 719 response: { 721 720 201: reportJsonSchema, 722 - 400: errorJsonSchema, 723 - 401: errorJsonSchema, 724 - 404: errorJsonSchema, 725 - 409: errorJsonSchema, 721 + 400: errorResponseSchema, 722 + 401: errorResponseSchema, 723 + 404: errorResponseSchema, 724 + 409: errorResponseSchema, 726 725 }, 727 726 }, 728 727 }, ··· 866 865 cursor: { type: ['string', 'null'] }, 867 866 }, 868 867 }, 869 - 400: errorJsonSchema, 868 + 400: errorResponseSchema, 870 869 }, 871 870 }, 872 871 }, ··· 949 948 }, 950 949 response: { 951 950 200: reportJsonSchema, 952 - 400: errorJsonSchema, 953 - 401: errorJsonSchema, 954 - 404: errorJsonSchema, 955 - 409: errorJsonSchema, 951 + 400: errorResponseSchema, 952 + 401: errorResponseSchema, 953 + 404: errorResponseSchema, 954 + 409: errorResponseSchema, 956 955 }, 957 956 }, 958 957 }, ··· 1176 1175 trustedPostThreshold: { type: 'number' }, 1177 1176 }, 1178 1177 }, 1179 - 400: errorJsonSchema, 1178 + 400: errorResponseSchema, 1180 1179 }, 1181 1180 }, 1182 1181 }, ··· 1258 1257 cursor: { type: ['string', 'null'] }, 1259 1258 }, 1260 1259 }, 1261 - 400: errorJsonSchema, 1262 - 401: errorJsonSchema, 1260 + 400: errorResponseSchema, 1261 + 401: errorResponseSchema, 1263 1262 }, 1264 1263 }, 1265 1264 }, ··· 1343 1342 }, 1344 1343 response: { 1345 1344 200: reportJsonSchema, 1346 - 400: errorJsonSchema, 1347 - 401: errorJsonSchema, 1348 - 403: errorJsonSchema, 1349 - 404: errorJsonSchema, 1350 - 409: errorJsonSchema, 1345 + 400: errorResponseSchema, 1346 + 401: errorResponseSchema, 1347 + 403: errorResponseSchema, 1348 + 404: errorResponseSchema, 1349 + 409: errorResponseSchema, 1351 1350 }, 1352 1351 }, 1353 1352 },
+6 -13
src/routes/notifications.ts
··· 1 1 import { eq, and, sql, desc } from 'drizzle-orm' 2 2 import type { FastifyPluginCallback } from 'fastify' 3 - import { badRequest } from '../lib/api-errors.js' 3 + import { badRequest, errorResponseSchema } from '../lib/api-errors.js' 4 4 import { notificationQuerySchema, markReadSchema } from '../validation/notifications.js' 5 5 import { notifications } from '../db/schema/notifications.js' 6 6 ··· 18 18 communityDid: { type: 'string' as const }, 19 19 read: { type: 'boolean' as const }, 20 20 createdAt: { type: 'string' as const, format: 'date-time' as const }, 21 - }, 22 - } 23 - 24 - const errorJsonSchema = { 25 - type: 'object' as const, 26 - properties: { 27 - error: { type: 'string' as const }, 28 21 }, 29 22 } 30 23 ··· 120 113 total: { type: 'number' }, 121 114 }, 122 115 }, 123 - 400: errorJsonSchema, 124 - 401: errorJsonSchema, 116 + 400: errorResponseSchema, 117 + 401: errorResponseSchema, 125 118 }, 126 119 }, 127 120 }, ··· 222 215 success: { type: 'boolean' }, 223 216 }, 224 217 }, 225 - 400: errorJsonSchema, 226 - 401: errorJsonSchema, 218 + 400: errorResponseSchema, 219 + 401: errorResponseSchema, 227 220 }, 228 221 }, 229 222 }, ··· 283 276 unread: { type: 'number' }, 284 277 }, 285 278 }, 286 - 401: errorJsonSchema, 279 + 401: errorResponseSchema, 287 280 }, 288 281 }, 289 282 },
+19 -27
src/routes/onboarding.ts
··· 1 1 import { eq, and, asc } from 'drizzle-orm' 2 2 import type { FastifyPluginCallback } from 'fastify' 3 3 import { getCommunityDid } from '../config/env.js' 4 - import { notFound, badRequest, forbidden } from '../lib/api-errors.js' 4 + import { notFound, badRequest, forbidden, errorResponseSchema } from '../lib/api-errors.js' 5 5 import { 6 6 createOnboardingFieldSchema, 7 7 updateOnboardingFieldSchema, ··· 53 53 }, 54 54 } 55 55 56 - const errorJsonSchema = { 57 - type: 'object' as const, 58 - properties: { 59 - error: { type: 'string' as const }, 60 - message: { type: 'string' as const }, 61 - }, 62 - } 63 - 64 56 // --------------------------------------------------------------------------- 65 57 // Helpers 66 58 // --------------------------------------------------------------------------- ··· 110 102 type: 'array' as const, 111 103 items: onboardingFieldJsonSchema, 112 104 }, 113 - 401: errorJsonSchema, 114 - 403: errorJsonSchema, 105 + 401: errorResponseSchema, 106 + 403: errorResponseSchema, 115 107 }, 116 108 }, 117 109 }, ··· 154 146 }, 155 147 response: { 156 148 201: onboardingFieldJsonSchema, 157 - 400: errorJsonSchema, 158 - 401: errorJsonSchema, 159 - 403: errorJsonSchema, 149 + 400: errorResponseSchema, 150 + 401: errorResponseSchema, 151 + 403: errorResponseSchema, 160 152 }, 161 153 }, 162 154 }, ··· 224 216 }, 225 217 response: { 226 218 200: onboardingFieldJsonSchema, 227 - 400: errorJsonSchema, 228 - 401: errorJsonSchema, 229 - 403: errorJsonSchema, 230 - 404: errorJsonSchema, 219 + 400: errorResponseSchema, 220 + 401: errorResponseSchema, 221 + 403: errorResponseSchema, 222 + 404: errorResponseSchema, 231 223 }, 232 224 }, 233 225 }, ··· 299 291 type: 'object' as const, 300 292 properties: { success: { type: 'boolean' as const } }, 301 293 }, 302 - 401: errorJsonSchema, 303 - 403: errorJsonSchema, 304 - 404: errorJsonSchema, 294 + 401: errorResponseSchema, 295 + 403: errorResponseSchema, 296 + 404: errorResponseSchema, 305 297 }, 306 298 }, 307 299 }, ··· 364 356 type: 'array' as const, 365 357 items: onboardingFieldJsonSchema, 366 358 }, 367 - 400: errorJsonSchema, 368 - 401: errorJsonSchema, 369 - 403: errorJsonSchema, 359 + 400: errorResponseSchema, 360 + 401: errorResponseSchema, 361 + 403: errorResponseSchema, 370 362 }, 371 363 }, 372 364 }, ··· 420 412 security: [{ bearerAuth: [] }], 421 413 response: { 422 414 200: onboardingStatusJsonSchema, 423 - 401: errorJsonSchema, 415 + 401: errorResponseSchema, 424 416 }, 425 417 }, 426 418 }, ··· 498 490 complete: { type: 'boolean' as const }, 499 491 }, 500 492 }, 501 - 400: errorJsonSchema, 502 - 401: errorJsonSchema, 493 + 400: errorResponseSchema, 494 + 401: errorResponseSchema, 503 495 }, 504 496 }, 505 497 },
+12 -19
src/routes/profiles.ts
··· 1 1 import { eq, and, sql } from 'drizzle-orm' 2 2 import type { FastifyPluginCallback } from 'fastify' 3 - import { notFound, badRequest } from '../lib/api-errors.js' 3 + import { notFound, badRequest, errorResponseSchema } from '../lib/api-errors.js' 4 4 import { 5 5 userPreferencesSchema, 6 6 communityPreferencesSchema, ··· 25 25 // OpenAPI JSON Schema definitions 26 26 // --------------------------------------------------------------------------- 27 27 28 - const errorJsonSchema = { 29 - type: 'object' as const, 30 - properties: { 31 - error: { type: 'string' as const }, 32 - }, 33 - } 34 - 35 28 const profileJsonSchema = { 36 29 type: 'object' as const, 37 30 properties: { ··· 199 192 }, 200 193 response: { 201 194 200: profileJsonSchema, 202 - 404: errorJsonSchema, 195 + 404: errorResponseSchema, 203 196 }, 204 197 }, 205 198 }, ··· 312 305 }, 313 306 response: { 314 307 200: reputationJsonSchema, 315 - 404: errorJsonSchema, 308 + 404: errorResponseSchema, 316 309 }, 317 310 }, 318 311 }, ··· 503 496 }, 504 497 }, 505 498 }, 506 - 400: errorJsonSchema, 507 - 401: errorJsonSchema, 499 + 400: errorResponseSchema, 500 + 401: errorResponseSchema, 508 501 }, 509 502 }, 510 503 }, ··· 562 555 security: [{ bearerAuth: [] }], 563 556 response: { 564 557 200: preferencesJsonSchema, 565 - 401: errorJsonSchema, 558 + 401: errorResponseSchema, 566 559 }, 567 560 }, 568 561 }, ··· 629 622 }, 630 623 response: { 631 624 200: preferencesJsonSchema, 632 - 400: errorJsonSchema, 633 - 401: errorJsonSchema, 625 + 400: errorResponseSchema, 626 + 401: errorResponseSchema, 634 627 }, 635 628 }, 636 629 }, ··· 725 718 }, 726 719 response: { 727 720 200: communityPrefsJsonSchema, 728 - 401: errorJsonSchema, 721 + 401: errorResponseSchema, 729 722 }, 730 723 }, 731 724 }, ··· 815 808 }, 816 809 response: { 817 810 200: communityPrefsJsonSchema, 818 - 400: errorJsonSchema, 819 - 401: errorJsonSchema, 811 + 400: errorResponseSchema, 812 + 401: errorResponseSchema, 820 813 }, 821 814 }, 822 815 }, ··· 908 901 security: [{ bearerAuth: [] }], 909 902 response: { 910 903 204: { type: 'null' }, 911 - 401: errorJsonSchema, 904 + 401: errorResponseSchema, 912 905 }, 913 906 }, 914 907 },
+47 -35
src/routes/reactions.ts
··· 2 2 import type { FastifyPluginCallback } from 'fastify' 3 3 import { getCommunityDid } from '../config/env.js' 4 4 import { createPdsClient } from '../lib/pds-client.js' 5 - import { notFound, forbidden, badRequest, conflict } from '../lib/api-errors.js' 5 + import { 6 + notFound, 7 + forbidden, 8 + badRequest, 9 + conflict, 10 + errorResponseSchema, 11 + sendError, 12 + } from '../lib/api-errors.js' 6 13 import { createReactionSchema, reactionQuerySchema } from '../validation/reactions.js' 7 14 import { reactions } from '../db/schema/reactions.js' 8 15 import { topics } from '../db/schema/topics.js' ··· 34 41 type: { type: 'string' as const }, 35 42 cid: { type: 'string' as const }, 36 43 createdAt: { type: 'string' as const, format: 'date-time' as const }, 37 - }, 38 - } 39 - 40 - const errorJsonSchema = { 41 - type: 'object' as const, 42 - properties: { 43 - error: { type: 'string' as const }, 44 44 }, 45 45 } 46 46 ··· 148 148 createdAt: { type: 'string', format: 'date-time' }, 149 149 }, 150 150 }, 151 - 400: errorJsonSchema, 152 - 401: errorJsonSchema, 153 - 403: errorJsonSchema, 154 - 404: errorJsonSchema, 155 - 409: errorJsonSchema, 156 - 502: errorJsonSchema, 151 + 400: errorResponseSchema, 152 + 401: errorResponseSchema, 153 + 403: errorResponseSchema, 154 + 404: errorResponseSchema, 155 + 409: errorResponseSchema, 156 + 500: errorResponseSchema, 157 + 502: errorResponseSchema, 157 158 }, 158 159 }, 159 160 }, ··· 231 232 createdAt: now, 232 233 } 233 234 235 + // Write record to user's PDS 236 + let pdsResult: { uri: string; cid: string } 234 237 try { 235 - // Write record to user's PDS 236 - const result = await pdsClient.createRecord(user.did, COLLECTION, record) 237 - const rkey = extractRkey(result.uri) 238 + pdsResult = await pdsClient.createRecord(user.did, COLLECTION, record) 239 + } catch (err: unknown) { 240 + if (err instanceof Error && 'statusCode' in err) throw err 241 + app.log.error({ err, did: user.did }, 'PDS write failed for reaction creation') 242 + return sendError(reply, 502, 'Failed to write to remote PDS') 243 + } 238 244 245 + const rkey = extractRkey(pdsResult.uri) 246 + 247 + try { 239 248 // Track repo if this is user's first interaction 240 249 const repoManager = firehose.getRepoManager() 241 250 const alreadyTracked = await repoManager.isTracked(user.did) ··· 248 257 const inserted = await tx 249 258 .insert(reactions) 250 259 .values({ 251 - uri: result.uri, 260 + uri: pdsResult.uri, 252 261 rkey, 253 262 authorDid: user.did, 254 263 subjectUri, 255 264 subjectCid, 256 265 type: reactionType, 257 266 communityDid, 258 - cid: result.cid, 267 + cid: pdsResult.cid, 259 268 createdAt: new Date(now), 260 269 indexedAt: new Date(), 261 270 }) ··· 308 317 } 309 318 310 319 return await reply.status(201).send({ 311 - uri: result.uri, 312 - cid: result.cid, 320 + uri: pdsResult.uri, 321 + cid: pdsResult.cid, 313 322 rkey, 314 323 type: reactionType, 315 324 subjectUri, 316 325 createdAt: now, 317 326 }) 318 327 } catch (err: unknown) { 319 - if (err instanceof Error && 'statusCode' in err) { 320 - throw err // Re-throw ApiError instances 321 - } 328 + if (err instanceof Error && 'statusCode' in err) throw err 322 329 app.log.error({ err, did: user.did }, 'Failed to create reaction') 323 - return reply.status(502).send({ error: 'Failed to create reaction' }) 330 + return sendError(reply, 500, 'Failed to save reaction locally') 324 331 } 325 332 } 326 333 ) ··· 346 353 }, 347 354 response: { 348 355 204: { type: 'null' }, 349 - 401: errorJsonSchema, 350 - 403: errorJsonSchema, 351 - 404: errorJsonSchema, 352 - 502: errorJsonSchema, 356 + 401: errorResponseSchema, 357 + 403: errorResponseSchema, 358 + 404: errorResponseSchema, 359 + 500: errorResponseSchema, 360 + 502: errorResponseSchema, 353 361 }, 354 362 }, 355 363 }, ··· 381 389 382 390 const rkey = extractRkey(decodedUri) 383 391 392 + // Delete from PDS 384 393 try { 385 - // Delete from PDS 386 394 await pdsClient.deleteRecord(user.did, COLLECTION, rkey) 395 + } catch (err: unknown) { 396 + if (err instanceof Error && 'statusCode' in err) throw err 397 + app.log.error({ err, uri: decodedUri }, 'PDS delete failed for reaction') 398 + return sendError(reply, 502, 'Failed to delete record from remote PDS') 399 + } 387 400 401 + try { 388 402 // In transaction: delete from DB + decrement count on subject 389 403 await db.transaction(async (tx) => { 390 404 await tx ··· 412 426 413 427 return await reply.status(204).send() 414 428 } catch (err: unknown) { 415 - if (err instanceof Error && 'statusCode' in err) { 416 - throw err 417 - } 429 + if (err instanceof Error && 'statusCode' in err) throw err 418 430 app.log.error({ err, uri: decodedUri }, 'Failed to delete reaction') 419 - return await reply.status(502).send({ error: 'Failed to delete reaction' }) 431 + return sendError(reply, 500, 'Failed to delete reaction locally') 420 432 } 421 433 } 422 434 ) ··· 450 462 cursor: { type: ['string', 'null'] }, 451 463 }, 452 464 }, 453 - 400: errorJsonSchema, 465 + 400: errorResponseSchema, 454 466 }, 455 467 }, 456 468 },
+78 -58
src/routes/replies.ts
··· 2 2 import type { FastifyPluginCallback } from 'fastify' 3 3 import { getCommunityDid } from '../config/env.js' 4 4 import { createPdsClient } from '../lib/pds-client.js' 5 - import { notFound, forbidden, badRequest } from '../lib/api-errors.js' 5 + import { 6 + notFound, 7 + forbidden, 8 + badRequest, 9 + errorResponseSchema, 10 + sendError, 11 + } from '../lib/api-errors.js' 6 12 import { resolveMaxMaturity, maturityAllows } from '../lib/content-filter.js' 7 13 import type { MaturityUser } from '../lib/content-filter.js' 8 14 import { loadBlockMuteLists } from '../lib/block-mute.js' ··· 79 85 ozoneLabel: { type: ['string', 'null'] as const }, 80 86 createdAt: { type: 'string' as const, format: 'date-time' as const }, 81 87 indexedAt: { type: 'string' as const, format: 'date-time' as const }, 82 - }, 83 - } 84 - 85 - const errorJsonSchema = { 86 - type: 'object' as const, 87 - properties: { 88 - error: { type: 'string' as const }, 89 88 }, 90 89 } 91 90 ··· 219 218 createdAt: { type: 'string', format: 'date-time' }, 220 219 }, 221 220 }, 222 - 400: errorJsonSchema, 223 - 401: errorJsonSchema, 224 - 403: errorJsonSchema, 225 - 404: errorJsonSchema, 226 - 502: errorJsonSchema, 221 + 400: errorResponseSchema, 222 + 401: errorResponseSchema, 223 + 403: errorResponseSchema, 224 + 404: errorResponseSchema, 225 + 500: errorResponseSchema, 226 + 502: errorResponseSchema, 227 227 }, 228 228 }, 229 229 }, ··· 331 331 ...(labels ? { labels } : {}), 332 332 } 333 333 334 + // Write record to user's PDS 335 + let pdsResult: { uri: string; cid: string } 334 336 try { 335 - // Write record to user's PDS 336 - const result = await pdsClient.createRecord(user.did, COLLECTION, record) 337 - const rkey = extractRkey(result.uri) 337 + pdsResult = await pdsClient.createRecord(user.did, COLLECTION, record) 338 + } catch (err: unknown) { 339 + if (err instanceof Error && 'statusCode' in err) throw err 340 + app.log.error({ err, did: user.did }, 'PDS write failed for reply creation') 341 + return sendError(reply, 502, 'Failed to write to remote PDS') 342 + } 343 + 344 + const rkey = extractRkey(pdsResult.uri) 338 345 346 + try { 339 347 // Track repo if this is user's first post 340 348 const repoManager = firehose.getRepoManager() 341 349 const alreadyTracked = await repoManager.isTracked(user.did) ··· 348 356 await db 349 357 .insert(replies) 350 358 .values({ 351 - uri: result.uri, 359 + uri: pdsResult.uri, 352 360 rkey, 353 361 authorDid: user.did, 354 362 content, ··· 357 365 parentUri: parentRefUri, 358 366 parentCid: parentRefCid, 359 367 communityDid: topic.communityDid, 360 - cid: result.cid, 368 + cid: pdsResult.cid, 361 369 labels: labels ?? null, 362 370 reactionCount: 0, 363 371 moderationStatus: contentModerationStatus, ··· 369 377 set: { 370 378 content, 371 379 labels: labels ?? null, 372 - cid: result.cid, 380 + cid: pdsResult.cid, 373 381 moderationStatus: contentModerationStatus, 374 382 indexedAt: new Date(), 375 383 }, ··· 378 386 // Insert moderation queue entries if held 379 387 if (spamResult.held) { 380 388 const queueEntries = spamResult.reasons.map((r) => ({ 381 - contentUri: result.uri, 389 + contentUri: pdsResult.uri, 382 390 contentType: 'reply' as const, 383 391 authorDid: user.did, 384 392 communityDid: topic.communityDid, ··· 389 397 390 398 app.log.info( 391 399 { 392 - replyUri: result.uri, 400 + replyUri: pdsResult.uri, 393 401 reasons: spamResult.reasons.map((r) => r.reason), 394 402 authorDid: user.did, 395 403 }, ··· 413 421 if (!spamResult.held) { 414 422 notificationService 415 423 .notifyOnReply({ 416 - replyUri: result.uri, 424 + replyUri: pdsResult.uri, 417 425 actorDid: user.did, 418 426 topicUri: decodedTopicUri, 419 427 parentUri: parentRefUri, 420 428 communityDid: topic.communityDid, 421 429 }) 422 430 .catch((err: unknown) => { 423 - app.log.error({ err, replyUri: result.uri }, 'Reply notification failed') 431 + app.log.error({ err, replyUri: pdsResult.uri }, 'Reply notification failed') 424 432 }) 425 433 426 434 notificationService 427 435 .notifyOnMentions({ 428 436 content, 429 - subjectUri: result.uri, 437 + subjectUri: pdsResult.uri, 430 438 actorDid: user.did, 431 439 communityDid: topic.communityDid, 432 440 }) 433 441 .catch((err: unknown) => { 434 - app.log.error({ err, replyUri: result.uri }, 'Mention notification failed') 442 + app.log.error({ err, replyUri: pdsResult.uri }, 'Mention notification failed') 435 443 }) 436 444 437 445 // Fire-and-forget: record interaction graph edges 438 446 app.interactionGraphService 439 447 .recordReply(user.did, topic.authorDid, topic.communityDid) 440 448 .catch((err: unknown) => { 441 - app.log.warn({ err, replyUri: result.uri }, 'Interaction graph recordReply failed') 449 + app.log.warn( 450 + { err, replyUri: pdsResult.uri }, 451 + 'Interaction graph recordReply failed' 452 + ) 442 453 }) 443 454 444 455 app.interactionGraphService ··· 452 463 } 453 464 454 465 return await reply.status(201).send({ 455 - uri: result.uri, 456 - cid: result.cid, 466 + uri: pdsResult.uri, 467 + cid: pdsResult.cid, 457 468 rkey, 458 469 content, 459 470 moderationStatus: contentModerationStatus, 460 471 createdAt: now, 461 472 }) 462 473 } catch (err: unknown) { 463 - if (err instanceof Error && 'statusCode' in err) { 464 - throw err // Re-throw ApiError instances 465 - } 474 + if (err instanceof Error && 'statusCode' in err) throw err 466 475 app.log.error({ err, did: user.did }, 'Failed to create reply') 467 - return reply.status(502).send({ error: 'Failed to create reply' }) 476 + return sendError(reply, 500, 'Failed to save reply locally') 468 477 } 469 478 } 470 479 ) ··· 503 512 cursor: { type: ['string', 'null'] }, 504 513 }, 505 514 }, 506 - 400: errorJsonSchema, 507 - 404: errorJsonSchema, 515 + 400: errorResponseSchema, 516 + 404: errorResponseSchema, 508 517 }, 509 518 }, 510 519 }, ··· 696 705 }, 697 706 response: { 698 707 200: replyJsonSchema, 699 - 400: errorJsonSchema, 700 - 401: errorJsonSchema, 701 - 403: errorJsonSchema, 702 - 404: errorJsonSchema, 703 - 502: errorJsonSchema, 708 + 400: errorResponseSchema, 709 + 401: errorResponseSchema, 710 + 403: errorResponseSchema, 711 + 404: errorResponseSchema, 712 + 500: errorResponseSchema, 713 + 502: errorResponseSchema, 704 714 }, 705 715 }, 706 716 }, ··· 747 757 ...(resolvedLabels ? { labels: resolvedLabels } : {}), 748 758 } 749 759 760 + // Update record on user's PDS 761 + let pdsResult: { uri: string; cid: string } 750 762 try { 751 - const result = await pdsClient.updateRecord(user.did, COLLECTION, rkey, updatedRecord) 763 + pdsResult = await pdsClient.updateRecord(user.did, COLLECTION, rkey, updatedRecord) 764 + } catch (err: unknown) { 765 + if (err instanceof Error && 'statusCode' in err) throw err 766 + app.log.error({ err, uri: decodedUri }, 'PDS update failed for reply') 767 + return sendError(reply, 502, 'Failed to update record on remote PDS') 768 + } 752 769 770 + try { 753 771 // Build DB update set 754 772 const dbUpdates: Record<string, unknown> = { 755 773 content, 756 - cid: result.cid, 774 + cid: pdsResult.cid, 757 775 indexedAt: new Date(), 758 776 } 759 777 if (labels !== undefined) dbUpdates.labels = labels ··· 771 789 772 790 return await reply.status(200).send(serializeReply(updatedRow)) 773 791 } catch (err: unknown) { 774 - if (err instanceof Error && 'statusCode' in err) { 775 - throw err // Re-throw ApiError instances 776 - } 792 + if (err instanceof Error && 'statusCode' in err) throw err 777 793 app.log.error({ err, uri: decodedUri }, 'Failed to update reply') 778 - return await reply.status(502).send({ error: 'Failed to update reply' }) 794 + return sendError(reply, 500, 'Failed to save reply update locally') 779 795 } 780 796 } 781 797 ) ··· 801 817 }, 802 818 response: { 803 819 204: { type: 'null' }, 804 - 401: errorJsonSchema, 805 - 403: errorJsonSchema, 806 - 404: errorJsonSchema, 807 - 502: errorJsonSchema, 820 + 401: errorResponseSchema, 821 + 403: errorResponseSchema, 822 + 404: errorResponseSchema, 823 + 500: errorResponseSchema, 824 + 502: errorResponseSchema, 808 825 }, 809 826 }, 810 827 }, ··· 840 857 throw forbidden('Not authorized to delete this reply') 841 858 } 842 859 843 - try { 844 - // Author: delete from PDS AND DB 845 - // Moderator: delete from DB only (leave record on PDS) 846 - if (isAuthor) { 847 - const rkey = extractRkey(decodedUri) 860 + // Author: delete from PDS; moderator: skip PDS deletion 861 + if (isAuthor) { 862 + const rkey = extractRkey(decodedUri) 863 + try { 848 864 await pdsClient.deleteRecord(user.did, COLLECTION, rkey) 865 + } catch (err: unknown) { 866 + if (err instanceof Error && 'statusCode' in err) throw err 867 + app.log.error({ err, uri: decodedUri }, 'PDS delete failed for reply') 868 + return sendError(reply, 502, 'Failed to delete record from remote PDS') 849 869 } 870 + } 850 871 872 + try { 851 873 // Soft-delete reply and update topic replyCount in a transaction 852 874 await db.transaction(async (tx) => { 853 875 await tx ··· 864 886 865 887 return await reply.status(204).send() 866 888 } catch (err: unknown) { 867 - if (err instanceof Error && 'statusCode' in err) { 868 - throw err 869 - } 889 + if (err instanceof Error && 'statusCode' in err) throw err 870 890 app.log.error({ err, uri: decodedUri }, 'Failed to delete reply') 871 - return await reply.status(502).send({ error: 'Failed to delete reply' }) 891 + return sendError(reply, 500, 'Failed to delete reply locally') 872 892 } 873 893 } 874 894 )
+2 -9
src/routes/search.ts
··· 1 1 import { sql } from 'drizzle-orm' 2 2 import type { FastifyPluginCallback } from 'fastify' 3 3 import { getCommunityDid } from '../config/env.js' 4 - import { badRequest } from '../lib/api-errors.js' 4 + import { badRequest, errorResponseSchema } from '../lib/api-errors.js' 5 5 import { loadMutedWords, contentMatchesMutedWords } from '../lib/muted-words.js' 6 6 import { createEmbeddingService } from '../services/embedding.js' 7 7 import { searchQuerySchema } from '../validation/search.js' ··· 29 29 rootUri: { type: ['string', 'null'] as const }, 30 30 rootTitle: { type: ['string', 'null'] as const }, 31 31 isMutedWord: { type: 'boolean' as const }, 32 - }, 33 - } 34 - 35 - const errorJsonSchema = { 36 - type: 'object' as const, 37 - properties: { 38 - error: { type: 'string' as const }, 39 32 }, 40 33 } 41 34 ··· 238 231 }, 239 232 }, 240 233 }, 241 - 400: errorJsonSchema, 234 + 400: errorResponseSchema, 242 235 }, 243 236 }, 244 237 },
+3 -6
src/routes/setup.ts
··· 1 1 import { z } from 'zod/v4' 2 2 import type { FastifyPluginCallback } from 'fastify' 3 + import { sendError } from '../lib/api-errors.js' 3 4 4 5 // --------------------------------------------------------------------------- 5 6 // Zod schemas for request validation ··· 38 39 return await reply.status(200).send(status) 39 40 } catch (err: unknown) { 40 41 app.log.error({ err }, 'Failed to get setup status') 41 - return await reply.status(502).send({ 42 - error: 'Service temporarily unavailable', 43 - }) 42 + return sendError(reply, 500, 'Service temporarily unavailable') 44 43 } 45 44 }) 46 45 ··· 81 80 return await reply.status(200).send(result) 82 81 } catch (err: unknown) { 83 82 app.log.error({ err }, 'Failed to initialize community') 84 - return await reply.status(502).send({ 85 - error: 'Service temporarily unavailable', 86 - }) 83 + return sendError(reply, 500, 'Service temporarily unavailable') 87 84 } 88 85 } 89 86 )
+76 -56
src/routes/topics.ts
··· 2 2 import type { FastifyPluginCallback } from 'fastify' 3 3 import { getCommunityDid } from '../config/env.js' 4 4 import { createPdsClient } from '../lib/pds-client.js' 5 - import { notFound, forbidden, badRequest } from '../lib/api-errors.js' 5 + import { 6 + notFound, 7 + forbidden, 8 + badRequest, 9 + errorResponseSchema, 10 + sendError, 11 + } from '../lib/api-errors.js' 6 12 import { resolveMaxMaturity, allowedRatings, maturityAllows } from '../lib/content-filter.js' 7 13 import type { MaturityUser } from '../lib/content-filter.js' 8 14 import { createTopicSchema, updateTopicSchema, topicQuerySchema } from '../validation/topics.js' ··· 81 87 lastActivityAt: { type: 'string' as const, format: 'date-time' as const }, 82 88 createdAt: { type: 'string' as const, format: 'date-time' as const }, 83 89 indexedAt: { type: 'string' as const, format: 'date-time' as const }, 84 - }, 85 - } 86 - 87 - const errorJsonSchema = { 88 - type: 'object' as const, 89 - properties: { 90 - error: { type: 'string' as const }, 91 90 }, 92 91 } 93 92 ··· 231 230 createdAt: { type: 'string', format: 'date-time' }, 232 231 }, 233 232 }, 234 - 400: errorJsonSchema, 235 - 401: errorJsonSchema, 236 - 403: errorJsonSchema, 237 - 502: errorJsonSchema, 233 + 400: errorResponseSchema, 234 + 401: errorResponseSchema, 235 + 403: errorResponseSchema, 236 + 500: errorResponseSchema, 237 + 502: errorResponseSchema, 238 238 }, 239 239 }, 240 240 }, ··· 354 354 ...(labels ? { labels } : {}), 355 355 } 356 356 357 + // Write record to user's PDS 358 + let pdsResult: { uri: string; cid: string } 357 359 try { 358 - // Write record to user's PDS 359 - const result = await pdsClient.createRecord(user.did, COLLECTION, record) 360 - const rkey = extractRkey(result.uri) 360 + pdsResult = await pdsClient.createRecord(user.did, COLLECTION, record) 361 + } catch (err: unknown) { 362 + if (err instanceof Error && 'statusCode' in err) throw err 363 + app.log.error({ err, did: user.did }, 'PDS write failed for topic creation') 364 + return sendError(reply, 502, 'Failed to write to remote PDS') 365 + } 366 + 367 + const rkey = extractRkey(pdsResult.uri) 361 368 369 + try { 362 370 // Track repo if this is user's first post 363 371 const repoManager = firehose.getRepoManager() 364 372 const alreadyTracked = await repoManager.isTracked(user.did) ··· 371 379 await db 372 380 .insert(topics) 373 381 .values({ 374 - uri: result.uri, 382 + uri: pdsResult.uri, 375 383 rkey, 376 384 authorDid: user.did, 377 385 title, ··· 380 388 tags: tags ?? [], 381 389 labels: labels ?? null, 382 390 communityDid, 383 - cid: result.cid, 391 + cid: pdsResult.cid, 384 392 replyCount: 0, 385 393 reactionCount: 0, 386 394 moderationStatus: contentModerationStatus, ··· 396 404 category, 397 405 tags: tags ?? [], 398 406 labels: labels ?? null, 399 - cid: result.cid, 407 + cid: pdsResult.cid, 400 408 moderationStatus: contentModerationStatus, 401 409 indexedAt: new Date(), 402 410 }, ··· 405 413 // Insert moderation queue entries if held 406 414 if (spamResult.held) { 407 415 const queueEntries = spamResult.reasons.map((r) => ({ 408 - contentUri: result.uri, 416 + contentUri: pdsResult.uri, 409 417 contentType: 'topic' as const, 410 418 authorDid: user.did, 411 419 communityDid, ··· 416 424 417 425 app.log.info( 418 426 { 419 - topicUri: result.uri, 427 + topicUri: pdsResult.uri, 420 428 reasons: spamResult.reasons.map((r) => r.reason), 421 429 authorDid: user.did, 422 430 }, ··· 433 441 crossPostService 434 442 .crossPostTopic({ 435 443 did: user.did, 436 - topicUri: result.uri, 444 + topicUri: pdsResult.uri, 437 445 title, 438 446 content, 439 447 category, 440 448 communityDid, 441 449 }) 442 450 .catch((err: unknown) => { 443 - app.log.error({ err, topicUri: result.uri }, 'Cross-posting failed') 451 + app.log.error({ err, topicUri: pdsResult.uri }, 'Cross-posting failed') 444 452 }) 445 453 } 446 454 ··· 449 457 notificationService 450 458 .notifyOnMentions({ 451 459 content, 452 - subjectUri: result.uri, 460 + subjectUri: pdsResult.uri, 453 461 actorDid: user.did, 454 462 communityDid, 455 463 }) 456 464 .catch((err: unknown) => { 457 - app.log.error({ err, topicUri: result.uri }, 'Mention notification failed') 465 + app.log.error({ err, topicUri: pdsResult.uri }, 'Mention notification failed') 458 466 }) 459 467 } 460 468 461 469 return await reply.status(201).send({ 462 - uri: result.uri, 463 - cid: result.cid, 470 + uri: pdsResult.uri, 471 + cid: pdsResult.cid, 464 472 rkey, 465 473 title, 466 474 category, ··· 468 476 createdAt: now, 469 477 }) 470 478 } catch (err: unknown) { 479 + if (err instanceof Error && 'statusCode' in err) throw err 471 480 app.log.error({ err, did: user.did }, 'Failed to create topic') 472 - return reply.status(502).send({ error: 'Failed to create topic' }) 481 + return sendError(reply, 500, 'Failed to save topic locally') 473 482 } 474 483 } 475 484 ) ··· 503 512 cursor: { type: ['string', 'null'] }, 504 513 }, 505 514 }, 506 - 400: errorJsonSchema, 515 + 400: errorResponseSchema, 507 516 }, 508 517 }, 509 518 }, ··· 761 770 }, 762 771 response: { 763 772 200: topicJsonSchema, 764 - 403: errorJsonSchema, 765 - 404: errorJsonSchema, 773 + 403: errorResponseSchema, 774 + 404: errorResponseSchema, 766 775 }, 767 776 }, 768 777 }, ··· 829 838 }, 830 839 response: { 831 840 200: topicJsonSchema, 832 - 403: errorJsonSchema, 833 - 404: errorJsonSchema, 841 + 403: errorResponseSchema, 842 + 404: errorResponseSchema, 834 843 }, 835 844 }, 836 845 }, ··· 932 941 }, 933 942 response: { 934 943 200: topicJsonSchema, 935 - 400: errorJsonSchema, 936 - 401: errorJsonSchema, 937 - 403: errorJsonSchema, 938 - 404: errorJsonSchema, 939 - 502: errorJsonSchema, 944 + 400: errorResponseSchema, 945 + 401: errorResponseSchema, 946 + 403: errorResponseSchema, 947 + 404: errorResponseSchema, 948 + 500: errorResponseSchema, 949 + 502: errorResponseSchema, 940 950 }, 941 951 }, 942 952 }, ··· 985 995 ...(resolvedLabels ? { labels: resolvedLabels } : {}), 986 996 } 987 997 998 + // Update record on user's PDS 999 + let pdsResult: { uri: string; cid: string } 988 1000 try { 989 - const result = await pdsClient.updateRecord(user.did, COLLECTION, rkey, updatedRecord) 1001 + pdsResult = await pdsClient.updateRecord(user.did, COLLECTION, rkey, updatedRecord) 1002 + } catch (err: unknown) { 1003 + if (err instanceof Error && 'statusCode' in err) throw err 1004 + app.log.error({ err, uri: decodedUri }, 'PDS update failed for topic') 1005 + return sendError(reply, 502, 'Failed to update record on remote PDS') 1006 + } 990 1007 1008 + try { 991 1009 // Build DB update set 992 1010 const dbUpdates: Record<string, unknown> = { 993 - cid: result.cid, 1011 + cid: pdsResult.cid, 994 1012 indexedAt: new Date(), 995 1013 } 996 1014 if (updates.title !== undefined) dbUpdates.title = updates.title ··· 1012 1030 1013 1031 return await reply.status(200).send(serializeTopic(updatedRow)) 1014 1032 } catch (err: unknown) { 1015 - if (err instanceof Error && 'statusCode' in err) { 1016 - throw err // Re-throw ApiError instances 1017 - } 1033 + if (err instanceof Error && 'statusCode' in err) throw err 1018 1034 app.log.error({ err, uri: decodedUri }, 'Failed to update topic') 1019 - return await reply.status(502).send({ error: 'Failed to update topic' }) 1035 + return sendError(reply, 500, 'Failed to save topic update locally') 1020 1036 } 1021 1037 } 1022 1038 ) ··· 1042 1058 }, 1043 1059 response: { 1044 1060 204: { type: 'null' }, 1045 - 401: errorJsonSchema, 1046 - 403: errorJsonSchema, 1047 - 404: errorJsonSchema, 1048 - 502: errorJsonSchema, 1061 + 401: errorResponseSchema, 1062 + 403: errorResponseSchema, 1063 + 404: errorResponseSchema, 1064 + 500: errorResponseSchema, 1065 + 502: errorResponseSchema, 1049 1066 }, 1050 1067 }, 1051 1068 }, ··· 1081 1098 throw forbidden('Not authorized to delete this topic') 1082 1099 } 1083 1100 1084 - try { 1085 - // Author: delete from PDS AND DB 1086 - // Moderator: delete from DB only (leave record on PDS) 1087 - if (isAuthor) { 1088 - const rkey = extractRkey(decodedUri) 1101 + // Author: delete from PDS; moderator: skip PDS deletion 1102 + if (isAuthor) { 1103 + const rkey = extractRkey(decodedUri) 1104 + try { 1089 1105 await pdsClient.deleteRecord(user.did, COLLECTION, rkey) 1106 + } catch (err: unknown) { 1107 + if (err instanceof Error && 'statusCode' in err) throw err 1108 + app.log.error({ err, uri: decodedUri }, 'PDS delete failed for topic') 1109 + return sendError(reply, 502, 'Failed to delete record from remote PDS') 1090 1110 } 1111 + } 1091 1112 1113 + try { 1092 1114 // Best-effort cross-post deletion (fire-and-forget) 1093 1115 crossPostService.deleteCrossPosts(decodedUri, user.did).catch((err: unknown) => { 1094 1116 app.log.warn({ err, topicUri: decodedUri }, 'Failed to delete cross-posts') ··· 1099 1121 1100 1122 return await reply.status(204).send() 1101 1123 } catch (err: unknown) { 1102 - if (err instanceof Error && 'statusCode' in err) { 1103 - throw err 1104 - } 1124 + if (err instanceof Error && 'statusCode' in err) throw err 1105 1125 app.log.error({ err, uri: decodedUri }, 'Failed to delete topic') 1106 - return await reply.status(502).send({ error: 'Failed to delete topic' }) 1126 + return sendError(reply, 500, 'Failed to delete topic locally') 1107 1127 } 1108 1128 } 1109 1129 )
+5 -12
src/routes/uploads.ts
··· 1 1 import type { FastifyPluginCallback } from 'fastify' 2 2 import sharp from 'sharp' 3 - import { badRequest } from '../lib/api-errors.js' 3 + import { badRequest, errorResponseSchema } from '../lib/api-errors.js' 4 4 import { communityProfiles } from '../db/schema/community-profiles.js' 5 5 6 6 const ALLOWED_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']) ··· 11 11 // --------------------------------------------------------------------------- 12 12 // OpenAPI JSON Schema definitions 13 13 // --------------------------------------------------------------------------- 14 - 15 - const errorJsonSchema = { 16 - type: 'object' as const, 17 - properties: { 18 - error: { type: 'string' as const }, 19 - }, 20 - } 21 14 22 15 const uploadResponseJsonSchema = { 23 16 type: 'object' as const, ··· 65 58 params: paramsJsonSchema, 66 59 response: { 67 60 200: uploadResponseJsonSchema, 68 - 400: errorJsonSchema, 69 - 401: errorJsonSchema, 61 + 400: errorResponseSchema, 62 + 401: errorResponseSchema, 70 63 }, 71 64 }, 72 65 }, ··· 130 123 params: paramsJsonSchema, 131 124 response: { 132 125 200: uploadResponseJsonSchema, 133 - 400: errorJsonSchema, 134 - 401: errorJsonSchema, 126 + 400: errorResponseSchema, 127 + 401: errorResponseSchema, 135 128 }, 136 129 }, 137 130 },
+22 -14
tests/unit/routes/auth.test.ts
··· 232 232 }) 233 233 234 234 expect(response.statusCode).toBe(502) 235 - const body = response.json<{ error: string }>() 236 - expect(body.error).toBe('Failed to initiate login') 235 + const body = response.json<{ error: string; message: string; statusCode: number }>() 236 + expect(body.error).toBe('Bad Gateway') 237 + expect(body.message).toBe('Failed to initiate login') 238 + expect(body.statusCode).toBe(502) 237 239 }) 238 240 }) 239 241 ··· 493 495 expect(body.error).toBe('Invalid or expired token') 494 496 }) 495 497 496 - it('returns 502 when session service throws', async () => { 498 + it('returns 500 when session service throws', async () => { 497 499 validateAccessTokenFn.mockRejectedValueOnce(new Error('Valkey down')) 498 500 499 501 const response = await app.inject({ ··· 504 506 }, 505 507 }) 506 508 507 - expect(response.statusCode).toBe(502) 508 - const body = response.json<{ error: string }>() 509 - expect(body.error).toBe('Service temporarily unavailable') 509 + expect(response.statusCode).toBe(500) 510 + const body = response.json<{ error: string; message: string; statusCode: number }>() 511 + expect(body.error).toBe('Internal Server Error') 512 + expect(body.message).toBe('Service temporarily unavailable') 513 + expect(body.statusCode).toBe(500) 510 514 }) 511 515 }) 512 516 ··· 515 519 // ========================================================================= 516 520 517 521 describe('service error handling', () => { 518 - it('returns 502 when refresh service throws', async () => { 522 + it('returns 500 when refresh service throws', async () => { 519 523 refreshSessionFn.mockRejectedValueOnce(new Error('Valkey down')) 520 524 521 525 const response = await app.inject({ ··· 524 528 cookies: { barazo_refresh: TEST_SID }, 525 529 }) 526 530 527 - expect(response.statusCode).toBe(502) 528 - const body = response.json<{ error: string }>() 529 - expect(body.error).toBe('Service temporarily unavailable') 531 + expect(response.statusCode).toBe(500) 532 + const body = response.json<{ error: string; message: string; statusCode: number }>() 533 + expect(body.error).toBe('Internal Server Error') 534 + expect(body.message).toBe('Service temporarily unavailable') 535 + expect(body.statusCode).toBe(500) 530 536 }) 531 537 532 - it('returns 502 when delete service throws', async () => { 538 + it('returns 500 when delete service throws', async () => { 533 539 deleteSessionFn.mockRejectedValueOnce(new Error('Valkey down')) 534 540 535 541 const response = await app.inject({ ··· 538 544 cookies: { barazo_refresh: TEST_SID }, 539 545 }) 540 546 541 - expect(response.statusCode).toBe(502) 542 - const body = response.json<{ error: string }>() 543 - expect(body.error).toBe('Service temporarily unavailable') 547 + expect(response.statusCode).toBe(500) 548 + const body = response.json<{ error: string; message: string; statusCode: number }>() 549 + expect(body.error).toBe('Internal Server Error') 550 + expect(body.message).toBe('Service temporarily unavailable') 551 + expect(body.statusCode).toBe(500) 544 552 }) 545 553 }) 546 554
+12 -6
tests/unit/routes/setup.test.ts
··· 145 145 }) 146 146 }) 147 147 148 - it('returns 502 when service throws', async () => { 148 + it('returns 500 when service throws', async () => { 149 149 getStatusFn.mockRejectedValueOnce(new Error('DB down')) 150 150 151 151 const response = await app.inject({ ··· 153 153 url: '/api/setup/status', 154 154 }) 155 155 156 - expect(response.statusCode).toBe(502) 157 - expect(response.json<{ error: string }>().error).toBe('Service temporarily unavailable') 156 + expect(response.statusCode).toBe(500) 157 + const body = response.json<{ error: string; message: string; statusCode: number }>() 158 + expect(body.error).toBe('Internal Server Error') 159 + expect(body.message).toBe('Service temporarily unavailable') 160 + expect(body.statusCode).toBe(500) 158 161 }) 159 162 }) 160 163 ··· 376 379 expect(response.json<{ error: string }>().error).toBe('Invalid request body') 377 380 }) 378 381 379 - it('returns 502 when service throws', async () => { 382 + it('returns 500 when service throws', async () => { 380 383 validateAccessTokenFn.mockResolvedValueOnce(makeMockSession()) 381 384 initializeFn.mockRejectedValueOnce(new Error('DB down')) 382 385 ··· 389 392 payload: {}, 390 393 }) 391 394 392 - expect(response.statusCode).toBe(502) 393 - expect(response.json<{ error: string }>().error).toBe('Service temporarily unavailable') 395 + expect(response.statusCode).toBe(500) 396 + const body = response.json<{ error: string; message: string; statusCode: number }>() 397 + expect(body.error).toBe('Internal Server Error') 398 + expect(body.message).toBe('Service temporarily unavailable') 399 + expect(body.statusCode).toBe(500) 394 400 }) 395 401 }) 396 402 })