WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

refactor(appview): extract shared error handling, ban check middleware, and ForumAgent helper (#52)

* refactor(appview): extract shared error handling, ban check middleware, and ForumAgent helper

Eliminates ~500 lines of duplicated boilerplate across all route handlers by
extracting three reusable patterns:

- handleReadError/handleWriteError/handleSecurityCheckError: centralized error
classification (programming errors re-thrown, network→503, database→503,
unexpected→500) replacing ~40 inline try-catch blocks
- safeParseJsonBody: replaces 9 identical JSON parsing blocks
- requireNotBanned middleware: replaces duplicate ban-check-with-error-handling
in topics.ts and posts.ts
- getForumAgentOrError: replaces 6 identical ForumAgent availability checks
in mod.ts and admin.ts

* fix(review): address PR #52 review feedback on shared error handling refactor

C1: Update test assertions to match centralized error messages
C2: Add isProgrammingError re-throw to handleReadError
C3: Delete parseJsonBody — false JSDoc, deleted in favor of safeParseJsonBody
I1: Add 503 classification (isNetworkError + isDatabaseError) to handleReadError
I2: Add isNetworkError check to handleSecurityCheckError
I3: Restore original middleware ordering — ban check before permission check
M1: Add unit tests for route-errors.ts and require-not-banned.ts
Also: Remove redundant isProgrammingError guards in admin.ts

* fix(review): re-throw programming errors in fail-open GET topic catch blocks

Ban check, hidden-posts check, and mod-status check in GET /api/topics/:id
are fail-open (continue on transient DB failure). But without an
isProgrammingError guard, a TypeError from a code bug would silently skip
the safety check — banned users' content visible, hidden posts visible, or
locked topics accepting replies. isProgrammingError was already imported.

* refactor(appview): remove duplicate requireNotBanned from permissions.ts

PR 51 added requireNotBanned to permissions.ts; PR 52 introduced a
dedicated require-not-banned.ts with the improved implementation that
uses handleSecurityCheckError (proper network+DB classification, dynamic
operation name, correct 500 message). All callers already import from
require-not-banned.ts. Remove the stale copy and its now-unused imports
(getActiveBans, isProgrammingError, isDatabaseError).

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by

Malpercio
Claude
and committed by
GitHub
e8d6e692 25401f67

+1031 -630
+392
apps/appview/src/lib/__tests__/route-errors.test.ts
··· 1 + import { describe, it, expect, vi, afterEach } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { 4 + handleReadError, 5 + handleWriteError, 6 + handleSecurityCheckError, 7 + safeParseJsonBody, 8 + getForumAgentOrError, 9 + } from "../route-errors.js"; 10 + import type { AppContext } from "../app-context.js"; 11 + 12 + afterEach(() => { 13 + vi.restoreAllMocks(); 14 + }); 15 + 16 + /** 17 + * Build a one-route Hono app that calls the given handler helper. 18 + * Useful for testing error-returning functions without full test context. 19 + */ 20 + function makeApp( 21 + handler: (c: any) => Response | Promise<Response> 22 + ): Hono { 23 + const app = new Hono(); 24 + app.get("/test", (c) => handler(c)); 25 + app.post("/test", (c) => handler(c)); 26 + return app; 27 + } 28 + 29 + // ─── handleReadError ────────────────────────────────────────────────────────── 30 + 31 + describe("handleReadError", () => { 32 + it("returns 503 for network errors", async () => { 33 + const app = makeApp((c) => 34 + handleReadError(c, new Error("fetch failed"), "Failed to read resource", { 35 + operation: "GET /test", 36 + }) 37 + ); 38 + 39 + const res = await app.request("/test"); 40 + 41 + expect(res.status).toBe(503); 42 + const data = await res.json(); 43 + expect(data.error).toBe( 44 + "Unable to reach external service. Please try again later." 45 + ); 46 + }); 47 + 48 + it("returns 503 for database errors", async () => { 49 + const app = makeApp((c) => 50 + handleReadError(c, new Error("database query failed"), "Failed to read resource", { 51 + operation: "GET /test", 52 + }) 53 + ); 54 + 55 + const res = await app.request("/test"); 56 + 57 + expect(res.status).toBe(503); 58 + const data = await res.json(); 59 + expect(data.error).toBe( 60 + "Database temporarily unavailable. Please try again later." 61 + ); 62 + }); 63 + 64 + it("returns 500 for unexpected errors", async () => { 65 + const app = makeApp((c) => 66 + handleReadError(c, new Error("Something went wrong"), "Failed to read resource", { 67 + operation: "GET /test", 68 + }) 69 + ); 70 + 71 + const res = await app.request("/test"); 72 + 73 + expect(res.status).toBe(500); 74 + const data = await res.json(); 75 + expect(data.error).toBe("Failed to read resource. Please contact support if this persists."); 76 + }); 77 + 78 + it("logs structured context on error", async () => { 79 + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); 80 + const app = makeApp((c) => 81 + handleReadError(c, new Error("boom"), "Failed to fetch things", { 82 + operation: "GET /test", 83 + resourceId: "123", 84 + }) 85 + ); 86 + 87 + await app.request("/test"); 88 + 89 + expect(spy).toHaveBeenCalledWith( 90 + "Failed to fetch things", 91 + expect.objectContaining({ 92 + operation: "GET /test", 93 + resourceId: "123", 94 + error: "boom", 95 + }) 96 + ); 97 + }); 98 + 99 + it("re-throws TypeError (programming error) and logs CRITICAL", async () => { 100 + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); 101 + const programmingError = new TypeError("Cannot read property of undefined"); 102 + const app = makeApp((c) => 103 + handleReadError(c, programmingError, "Failed to read resource", { 104 + operation: "GET /test", 105 + }) 106 + ); 107 + 108 + // Hono catches re-thrown errors and returns 500 109 + const res = await app.request("/test"); 110 + expect(res.status).toBe(500); 111 + 112 + // CRITICAL log must be emitted before the re-throw 113 + expect(spy).toHaveBeenCalledWith( 114 + "CRITICAL: Programming error in GET /test", 115 + expect.objectContaining({ 116 + operation: "GET /test", 117 + error: "Cannot read property of undefined", 118 + stack: expect.any(String), 119 + }) 120 + ); 121 + 122 + // Normal error log must NOT be emitted (re-throw bypasses it) 123 + expect(spy).not.toHaveBeenCalledWith( 124 + "Failed to read resource", 125 + expect.any(Object) 126 + ); 127 + }); 128 + }); 129 + 130 + // ─── handleWriteError ───────────────────────────────────────────────────────── 131 + 132 + describe("handleWriteError", () => { 133 + it("returns 503 for network errors", async () => { 134 + const app = makeApp((c) => 135 + handleWriteError(c, new Error("fetch failed"), "Failed to create thing", { 136 + operation: "POST /test", 137 + }) 138 + ); 139 + 140 + const res = await app.request("/test", { method: "POST" }); 141 + 142 + expect(res.status).toBe(503); 143 + const data = await res.json(); 144 + expect(data.error).toBe( 145 + "Unable to reach external service. Please try again later." 146 + ); 147 + }); 148 + 149 + it("returns 503 for database errors", async () => { 150 + const app = makeApp((c) => 151 + handleWriteError(c, new Error("database connection lost"), "Failed to create thing", { 152 + operation: "POST /test", 153 + }) 154 + ); 155 + 156 + const res = await app.request("/test", { method: "POST" }); 157 + 158 + expect(res.status).toBe(503); 159 + const data = await res.json(); 160 + expect(data.error).toBe( 161 + "Database temporarily unavailable. Please try again later." 162 + ); 163 + }); 164 + 165 + it("returns 500 for unexpected errors", async () => { 166 + const app = makeApp((c) => 167 + handleWriteError(c, new Error("Something unexpected"), "Failed to create thing", { 168 + operation: "POST /test", 169 + }) 170 + ); 171 + 172 + const res = await app.request("/test", { method: "POST" }); 173 + 174 + expect(res.status).toBe(500); 175 + const data = await res.json(); 176 + expect(data.error).toBe("Failed to create thing. Please contact support if this persists."); 177 + }); 178 + 179 + it("re-throws TypeError (programming error) and logs CRITICAL", async () => { 180 + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); 181 + const programmingError = new TypeError("Cannot read property of null"); 182 + const app = makeApp((c) => 183 + handleWriteError(c, programmingError, "Failed to create thing", { 184 + operation: "POST /test", 185 + }) 186 + ); 187 + 188 + const res = await app.request("/test", { method: "POST" }); 189 + expect(res.status).toBe(500); 190 + 191 + expect(spy).toHaveBeenCalledWith( 192 + "CRITICAL: Programming error in POST /test", 193 + expect.objectContaining({ 194 + operation: "POST /test", 195 + error: "Cannot read property of null", 196 + stack: expect.any(String), 197 + }) 198 + ); 199 + 200 + expect(spy).not.toHaveBeenCalledWith( 201 + "Failed to create thing", 202 + expect.any(Object) 203 + ); 204 + }); 205 + }); 206 + 207 + // ─── handleSecurityCheckError ───────────────────────────────────────────────── 208 + 209 + describe("handleSecurityCheckError", () => { 210 + it("returns 503 for network errors", async () => { 211 + const app = makeApp((c) => 212 + handleSecurityCheckError(c, new Error("fetch failed"), "Unable to verify access", { 213 + operation: "POST /test - security check", 214 + }) 215 + ); 216 + 217 + const res = await app.request("/test", { method: "POST" }); 218 + 219 + expect(res.status).toBe(503); 220 + const data = await res.json(); 221 + expect(data.error).toBe( 222 + "Unable to reach external service. Please try again later." 223 + ); 224 + }); 225 + 226 + it("returns 503 for database errors", async () => { 227 + const app = makeApp((c) => 228 + handleSecurityCheckError(c, new Error("sql query failed"), "Unable to verify access", { 229 + operation: "POST /test - security check", 230 + }) 231 + ); 232 + 233 + const res = await app.request("/test", { method: "POST" }); 234 + 235 + expect(res.status).toBe(503); 236 + const data = await res.json(); 237 + expect(data.error).toBe( 238 + "Database temporarily unavailable. Please try again later." 239 + ); 240 + }); 241 + 242 + it("returns 500 for unexpected errors (fail closed)", async () => { 243 + vi.spyOn(console, "error").mockImplementation(() => {}); 244 + const app = makeApp((c) => 245 + handleSecurityCheckError(c, new Error("Something unexpected"), "Unable to verify access", { 246 + operation: "POST /test - security check", 247 + }) 248 + ); 249 + 250 + const res = await app.request("/test", { method: "POST" }); 251 + 252 + expect(res.status).toBe(500); 253 + const data = await res.json(); 254 + expect(data.error).toBe( 255 + "Unable to verify access. Please contact support if this persists." 256 + ); 257 + }); 258 + 259 + it("re-throws TypeError (programming error) and logs CRITICAL", async () => { 260 + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); 261 + const programmingError = new TypeError("Cannot read property of undefined"); 262 + const app = makeApp((c) => 263 + handleSecurityCheckError(c, programmingError, "Unable to verify access", { 264 + operation: "POST /test - security check", 265 + }) 266 + ); 267 + 268 + const res = await app.request("/test", { method: "POST" }); 269 + expect(res.status).toBe(500); 270 + 271 + expect(spy).toHaveBeenCalledWith( 272 + "CRITICAL: Programming error in POST /test - security check", 273 + expect.objectContaining({ 274 + operation: "POST /test - security check", 275 + error: "Cannot read property of undefined", 276 + stack: expect.any(String), 277 + }) 278 + ); 279 + 280 + expect(spy).not.toHaveBeenCalledWith( 281 + "Unable to verify access", 282 + expect.any(Object) 283 + ); 284 + }); 285 + }); 286 + 287 + // ─── safeParseJsonBody ──────────────────────────────────────────────────────── 288 + 289 + describe("safeParseJsonBody", () => { 290 + it("returns parsed body on valid JSON", async () => { 291 + const app = new Hono(); 292 + app.post("/test", async (c) => { 293 + const { body, error } = await safeParseJsonBody(c); 294 + if (error) return error; 295 + return c.json({ received: body }); 296 + }); 297 + 298 + const res = await app.request("/test", { 299 + method: "POST", 300 + headers: { "Content-Type": "application/json" }, 301 + body: JSON.stringify({ text: "hello" }), 302 + }); 303 + 304 + expect(res.status).toBe(200); 305 + const data = await res.json(); 306 + expect(data.received).toEqual({ text: "hello" }); 307 + }); 308 + 309 + it("returns 400 error on malformed JSON", async () => { 310 + const app = new Hono(); 311 + app.post("/test", async (c) => { 312 + const { body, error } = await safeParseJsonBody(c); 313 + if (error) return error; 314 + return c.json({ received: body }); 315 + }); 316 + 317 + const res = await app.request("/test", { 318 + method: "POST", 319 + headers: { "Content-Type": "application/json" }, 320 + body: "{ invalid json }", 321 + }); 322 + 323 + expect(res.status).toBe(400); 324 + const data = await res.json(); 325 + expect(data.error).toBe("Invalid JSON in request body"); 326 + }); 327 + }); 328 + 329 + // ─── getForumAgentOrError ───────────────────────────────────────────────────── 330 + 331 + describe("getForumAgentOrError", () => { 332 + it("returns 500 when ForumAgent is not configured", async () => { 333 + const appCtx = { 334 + forumAgent: null, 335 + config: { forumDid: "did:plc:forum" }, 336 + } as unknown as AppContext; 337 + 338 + const app = new Hono(); 339 + app.get("/test", (c) => { 340 + const result = getForumAgentOrError(appCtx, c, "GET /test"); 341 + if (result.error) return result.error; 342 + return c.json({ ok: true }); 343 + }); 344 + 345 + const res = await app.request("/test"); 346 + 347 + expect(res.status).toBe(500); 348 + const data = await res.json(); 349 + expect(data.error).toContain("Forum agent not available"); 350 + }); 351 + 352 + it("returns 503 when ForumAgent is not authenticated", async () => { 353 + const appCtx = { 354 + forumAgent: { getAgent: () => null }, 355 + config: { forumDid: "did:plc:forum" }, 356 + } as unknown as AppContext; 357 + 358 + const app = new Hono(); 359 + app.get("/test", (c) => { 360 + const result = getForumAgentOrError(appCtx, c, "GET /test"); 361 + if (result.error) return result.error; 362 + return c.json({ ok: true }); 363 + }); 364 + 365 + const res = await app.request("/test"); 366 + 367 + expect(res.status).toBe(503); 368 + const data = await res.json(); 369 + expect(data.error).toContain("not authenticated"); 370 + }); 371 + 372 + it("returns agent when ForumAgent is configured and authenticated", async () => { 373 + const mockAgent = { putRecord: vi.fn() }; 374 + const appCtx = { 375 + forumAgent: { getAgent: () => mockAgent }, 376 + config: { forumDid: "did:plc:forum" }, 377 + } as unknown as AppContext; 378 + 379 + const app = new Hono(); 380 + app.get("/test", (c) => { 381 + const { agent, error } = getForumAgentOrError(appCtx, c, "GET /test"); 382 + if (error) return error; 383 + return c.json({ hasAgent: agent !== null }); 384 + }); 385 + 386 + const res = await app.request("/test"); 387 + 388 + expect(res.status).toBe(200); 389 + const data = await res.json(); 390 + expect(data.hasAgent).toBe(true); 391 + }); 392 + });
+269
apps/appview/src/lib/route-errors.ts
··· 1 + import type { Context } from "hono"; 2 + import type { AtpAgent } from "@atproto/api"; 3 + import type { AppContext } from "./app-context.js"; 4 + import { isProgrammingError, isNetworkError, isDatabaseError } from "./errors.js"; 5 + 6 + /** 7 + * Structured context for error logging in route handlers. 8 + */ 9 + interface ErrorContext { 10 + operation: string; 11 + [key: string]: unknown; 12 + } 13 + 14 + /** 15 + * Format an error for structured logging. 16 + * Extracts message and optionally stack from Error instances. 17 + */ 18 + function formatError(error: unknown): { message: string; stack?: string } { 19 + if (error instanceof Error) { 20 + return { message: error.message, stack: error.stack }; 21 + } 22 + return { message: String(error) }; 23 + } 24 + 25 + /** 26 + * Handle errors in read-path route handlers (GET endpoints). 27 + * 28 + * 1. Re-throws programming errors (TypeError, ReferenceError, SyntaxError) 29 + * 2. Logs the error with structured context 30 + * 3. Classifies the error and returns the appropriate HTTP status: 31 + * - 503 for network errors (external service unreachable, user should retry) 32 + * - 503 for database errors (temporary, user should retry) 33 + * - 500 for unexpected errors (server bug, needs investigation) 34 + * 35 + * @param c - Hono context 36 + * @param error - The caught error 37 + * @param userMessage - User-facing error message (e.g., "Failed to retrieve forum metadata") 38 + * @param ctx - Structured logging context 39 + */ 40 + export function handleReadError( 41 + c: Context, 42 + error: unknown, 43 + userMessage: string, 44 + ctx: ErrorContext 45 + ): Response { 46 + if (isProgrammingError(error)) { 47 + const { message, stack } = formatError(error); 48 + console.error(`CRITICAL: Programming error in ${ctx.operation}`, { 49 + ...ctx, 50 + error: message, 51 + stack, 52 + }); 53 + throw error; 54 + } 55 + 56 + console.error(userMessage, { 57 + ...ctx, 58 + error: formatError(error).message, 59 + }); 60 + 61 + if (error instanceof Error && isNetworkError(error)) { 62 + return c.json( 63 + { error: "Unable to reach external service. Please try again later." }, 64 + 503 65 + ) as unknown as Response; 66 + } 67 + 68 + if (error instanceof Error && isDatabaseError(error)) { 69 + return c.json( 70 + { error: "Database temporarily unavailable. Please try again later." }, 71 + 503 72 + ) as unknown as Response; 73 + } 74 + 75 + return c.json( 76 + { error: `${userMessage}. Please contact support if this persists.` }, 77 + 500 78 + ) as unknown as Response; 79 + } 80 + 81 + /** 82 + * Handle errors in write-path route handlers (POST/PUT/DELETE endpoints). 83 + * 84 + * 1. Re-throws programming errors (TypeError, ReferenceError, SyntaxError) 85 + * 2. Logs the error with structured context 86 + * 3. Classifies the error and returns the appropriate HTTP status: 87 + * - 503 for network errors (PDS unreachable, user should retry) 88 + * - 503 for database errors (temporary, user should retry) 89 + * - 500 for unexpected errors (server bug, needs investigation) 90 + * 91 + * @param c - Hono context 92 + * @param error - The caught error 93 + * @param userMessage - User-facing error message (e.g., "Failed to create topic") 94 + * @param ctx - Structured logging context 95 + */ 96 + export function handleWriteError( 97 + c: Context, 98 + error: unknown, 99 + userMessage: string, 100 + ctx: ErrorContext 101 + ): Response { 102 + if (isProgrammingError(error)) { 103 + const { message, stack } = formatError(error); 104 + console.error(`CRITICAL: Programming error in ${ctx.operation}`, { 105 + ...ctx, 106 + error: message, 107 + stack, 108 + }); 109 + throw error; 110 + } 111 + 112 + console.error(userMessage, { 113 + ...ctx, 114 + error: formatError(error).message, 115 + }); 116 + 117 + if (error instanceof Error && isNetworkError(error)) { 118 + return c.json( 119 + { error: "Unable to reach external service. Please try again later." }, 120 + 503 121 + ) as unknown as Response; 122 + } 123 + 124 + if (error instanceof Error && isDatabaseError(error)) { 125 + return c.json( 126 + { error: "Database temporarily unavailable. Please try again later." }, 127 + 503 128 + ) as unknown as Response; 129 + } 130 + 131 + return c.json( 132 + { error: `${userMessage}. Please contact support if this persists.` }, 133 + 500 134 + ) as unknown as Response; 135 + } 136 + 137 + /** 138 + * Handle errors in security-critical checks (ban check, lock check, permission verification). 139 + * 140 + * Fail-closed: returns an error response that denies the operation. 141 + * 142 + * 1. Re-throws programming errors 143 + * 2. Returns 503 for network errors (external service unreachable, user should retry) 144 + * 3. Returns 503 for database errors (temporary, user should retry) 145 + * 4. Returns 500 for unexpected errors (denying access) 146 + * 147 + * @param c - Hono context 148 + * @param error - The caught error 149 + * @param userMessage - User-facing error message (e.g., "Unable to verify permissions") 150 + * @param ctx - Structured logging context 151 + */ 152 + export function handleSecurityCheckError( 153 + c: Context, 154 + error: unknown, 155 + userMessage: string, 156 + ctx: ErrorContext 157 + ): Response { 158 + if (isProgrammingError(error)) { 159 + const { message, stack } = formatError(error); 160 + console.error(`CRITICAL: Programming error in ${ctx.operation}`, { 161 + ...ctx, 162 + error: message, 163 + stack, 164 + }); 165 + throw error; 166 + } 167 + 168 + console.error(userMessage, { 169 + ...ctx, 170 + error: formatError(error).message, 171 + }); 172 + 173 + if (error instanceof Error && isNetworkError(error)) { 174 + return c.json( 175 + { error: "Unable to reach external service. Please try again later." }, 176 + 503 177 + ) as unknown as Response; 178 + } 179 + 180 + if (error instanceof Error && isDatabaseError(error)) { 181 + return c.json( 182 + { error: "Database temporarily unavailable. Please try again later." }, 183 + 503 184 + ) as unknown as Response; 185 + } 186 + 187 + return c.json( 188 + { error: `${userMessage}. Please contact support if this persists.` }, 189 + 500 190 + ) as unknown as Response; 191 + } 192 + 193 + /** 194 + * Get the ForumAgent's authenticated AtpAgent, or return an error response. 195 + * 196 + * Checks both that ForumAgent exists (server configuration) and that it's 197 + * authenticated (has valid credentials). Returns appropriate error responses: 198 + * - 500 if ForumAgent is not configured (server misconfiguration) 199 + * - 503 if ForumAgent is not authenticated (temporary, should retry) 200 + * 201 + * Usage: 202 + * ```typescript 203 + * const { agent, error } = getForumAgentOrError(ctx, c, "POST /api/mod/ban"); 204 + * if (error) return error; 205 + * // agent is guaranteed to be non-null here 206 + * ``` 207 + */ 208 + export function getForumAgentOrError( 209 + appCtx: AppContext, 210 + c: Context, 211 + operation: string 212 + ): { agent: AtpAgent; error: null } | { agent: null; error: Response } { 213 + if (!appCtx.forumAgent) { 214 + console.error("CRITICAL: ForumAgent not available", { 215 + operation, 216 + forumDid: appCtx.config.forumDid, 217 + }); 218 + return { 219 + agent: null, 220 + error: c.json({ 221 + error: "Forum agent not available. Server configuration issue.", 222 + }, 500) as unknown as Response, 223 + }; 224 + } 225 + 226 + const agent = appCtx.forumAgent.getAgent(); 227 + if (!agent) { 228 + console.error("ForumAgent not authenticated", { 229 + operation, 230 + forumDid: appCtx.config.forumDid, 231 + }); 232 + return { 233 + agent: null, 234 + error: c.json({ 235 + error: "Forum agent not authenticated. Please try again later.", 236 + }, 503) as unknown as Response, 237 + }; 238 + } 239 + 240 + return { agent, error: null }; 241 + } 242 + 243 + /** 244 + * Parse JSON body and return 400 if malformed. 245 + * 246 + * Returns `{ body, error }` where: 247 + * - On success: `{ body: parsedObject, error: null }` 248 + * - On failure: `{ body: null, error: Response }` — caller should `return error` 249 + * 250 + * Usage: 251 + * ```typescript 252 + * const { body, error } = await safeParseJsonBody(c); 253 + * if (error) return error; 254 + * const { text, boardUri } = body; 255 + * ``` 256 + */ 257 + export async function safeParseJsonBody( 258 + c: Context 259 + ): Promise<{ body: any; error: null } | { body: null; error: Response }> { 260 + try { 261 + const body = await c.req.json(); 262 + return { body, error: null }; 263 + } catch { 264 + return { 265 + body: null, 266 + error: c.json({ error: "Invalid JSON in request body" }, 400) as unknown as Response, 267 + }; 268 + } 269 + }
+171
apps/appview/src/middleware/__tests__/require-not-banned.test.ts
··· 1 + import { describe, it, expect, vi, afterEach } from "vitest"; 2 + import { Hono } from "hono"; 3 + import type { Variables } from "../../types.js"; 4 + import { requireNotBanned } from "../require-not-banned.js"; 5 + import type { AppContext } from "../../lib/app-context.js"; 6 + 7 + // Mock getActiveBans so tests don't need a real database 8 + vi.mock("../../routes/helpers.js", () => ({ 9 + getActiveBans: vi.fn(), 10 + })); 11 + 12 + // Import after mocking 13 + const { getActiveBans } = await import("../../routes/helpers.js"); 14 + const mockGetActiveBans = vi.mocked(getActiveBans); 15 + 16 + const stubCtx = {} as unknown as AppContext; 17 + 18 + /** 19 + * Build a minimal Hono app with requireNotBanned middleware. 20 + * The route handler sets c.get("user") before the middleware if user is provided. 21 + */ 22 + function makeApp(user: any | null): Hono<{ Variables: Variables }> { 23 + const app = new Hono<{ Variables: Variables }>(); 24 + 25 + // Simulate requireAuth by pre-setting the user variable 26 + app.use("/test", async (c, next) => { 27 + if (user !== null) { 28 + c.set("user", user); 29 + } 30 + await next(); 31 + }); 32 + 33 + app.post("/test", requireNotBanned(stubCtx), (c) => { 34 + return c.json({ ok: true }, 200); 35 + }); 36 + 37 + return app; 38 + } 39 + 40 + const mockUser = { 41 + did: "did:plc:test-user", 42 + handle: "testuser.test", 43 + pdsUrl: "https://test.pds", 44 + agent: {}, 45 + }; 46 + 47 + afterEach(() => { 48 + vi.restoreAllMocks(); 49 + }); 50 + 51 + describe("requireNotBanned", () => { 52 + it("returns 401 when no authenticated user is set", async () => { 53 + // No user set (requireAuth was not run or failed) 54 + const app = makeApp(null); 55 + 56 + const res = await app.request("/test", { method: "POST" }); 57 + 58 + expect(res.status).toBe(401); 59 + const data = await res.json(); 60 + expect(data.error).toBe("Authentication required"); 61 + }); 62 + 63 + it("returns 403 when user is actively banned", async () => { 64 + mockGetActiveBans.mockResolvedValueOnce(new Set([mockUser.did])); 65 + 66 + const app = makeApp(mockUser); 67 + const res = await app.request("/test", { method: "POST" }); 68 + 69 + expect(res.status).toBe(403); 70 + const data = await res.json(); 71 + expect(data.error).toBe("You are banned from this forum"); 72 + }); 73 + 74 + it("passes through to next handler when user is not banned", async () => { 75 + mockGetActiveBans.mockResolvedValueOnce(new Set()); 76 + 77 + const app = makeApp(mockUser); 78 + const res = await app.request("/test", { method: "POST" }); 79 + 80 + expect(res.status).toBe(200); 81 + const data = await res.json(); 82 + expect(data.ok).toBe(true); 83 + }); 84 + 85 + it("returns 503 when ban check fails with database error (fail closed)", async () => { 86 + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); 87 + mockGetActiveBans.mockRejectedValueOnce(new Error("database query failed")); 88 + 89 + const app = makeApp(mockUser); 90 + const res = await app.request("/test", { method: "POST" }); 91 + 92 + expect(res.status).toBe(503); 93 + const data = await res.json(); 94 + expect(data.error).toBe( 95 + "Database temporarily unavailable. Please try again later." 96 + ); 97 + 98 + expect(spy).toHaveBeenCalledWith( 99 + "Unable to verify ban status", 100 + expect.objectContaining({ 101 + userId: mockUser.did, 102 + error: "database query failed", 103 + }) 104 + ); 105 + }); 106 + 107 + it("returns 503 when ban check fails with network error (fail closed)", async () => { 108 + vi.spyOn(console, "error").mockImplementation(() => {}); 109 + mockGetActiveBans.mockRejectedValueOnce(new Error("fetch failed")); 110 + 111 + const app = makeApp(mockUser); 112 + const res = await app.request("/test", { method: "POST" }); 113 + 114 + expect(res.status).toBe(503); 115 + const data = await res.json(); 116 + expect(data.error).toBe( 117 + "Unable to reach external service. Please try again later." 118 + ); 119 + }); 120 + 121 + it("returns 500 when ban check fails with unexpected error (fail closed)", async () => { 122 + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); 123 + mockGetActiveBans.mockRejectedValueOnce(new Error("Unexpected internal error")); 124 + 125 + const app = makeApp(mockUser); 126 + const res = await app.request("/test", { method: "POST" }); 127 + 128 + expect(res.status).toBe(500); 129 + const data = await res.json(); 130 + expect(data.error).toBe( 131 + "Unable to verify ban status. Please contact support if this persists." 132 + ); 133 + 134 + expect(spy).toHaveBeenCalledWith( 135 + "Unable to verify ban status", 136 + expect.objectContaining({ 137 + userId: mockUser.did, 138 + error: "Unexpected internal error", 139 + }) 140 + ); 141 + }); 142 + 143 + it("re-throws TypeError from ban check (programming error)", async () => { 144 + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); 145 + const programmingError = new TypeError("Cannot read property 'has' of undefined"); 146 + mockGetActiveBans.mockImplementationOnce(() => { 147 + throw programmingError; 148 + }); 149 + 150 + const app = makeApp(mockUser); 151 + // Hono's global error handler catches the re-throw and returns 500 152 + const res = await app.request("/test", { method: "POST" }); 153 + expect(res.status).toBe(500); 154 + 155 + // CRITICAL log emitted before re-throw 156 + expect(spy).toHaveBeenCalledWith( 157 + expect.stringContaining("CRITICAL: Programming error in"), 158 + expect.objectContaining({ 159 + userId: mockUser.did, 160 + error: "Cannot read property 'has' of undefined", 161 + stack: expect.any(String), 162 + }) 163 + ); 164 + 165 + // Normal error log was NOT emitted (re-throw bypasses it) 166 + expect(spy).not.toHaveBeenCalledWith( 167 + "Unable to verify ban status", 168 + expect.any(Object) 169 + ); 170 + }); 171 + });
-57
apps/appview/src/middleware/permissions.ts
··· 3 3 import type { Variables } from "../types.js"; 4 4 import { memberships, roles } from "@atbb/db"; 5 5 import { eq, and } from "drizzle-orm"; 6 - import { getActiveBans } from "../routes/helpers.js"; 7 - import { isProgrammingError, isDatabaseError } from "../lib/errors.js"; 8 6 9 7 /** 10 8 * Check if a user has a specific permission. ··· 257 255 error: "Insufficient role", 258 256 required: minRole 259 257 }, 403); 260 - } 261 - 262 - await next(); 263 - }; 264 - } 265 - 266 - /** 267 - * Require the authenticated user to not be banned. 268 - * 269 - * Must run after requireAuth (relies on c.get("user") being set). 270 - * Returns 403 if the user is banned. 271 - * Fails closed on errors (denies access on DB failure). 272 - */ 273 - export function requireNotBanned(ctx: AppContext) { 274 - return async (c: Context<{ Variables: Variables }>, next: Next) => { 275 - const user = c.get("user"); 276 - 277 - if (!user) { 278 - return c.json({ error: "Authentication required" }, 401); 279 - } 280 - 281 - try { 282 - const bannedUsers = await getActiveBans(ctx.db, [user.did]); 283 - if (bannedUsers.has(user.did)) { 284 - return c.json({ error: "You are banned from this forum" }, 403); 285 - } 286 - } catch (error) { 287 - if (isProgrammingError(error)) { 288 - console.error("CRITICAL: Programming error in ban check", { 289 - operation: "requireNotBanned", 290 - userId: user.did, 291 - error: error instanceof Error ? error.message : String(error), 292 - stack: error instanceof Error ? error.stack : undefined, 293 - }); 294 - throw error; 295 - } 296 - 297 - console.error("Failed to check ban status", { 298 - operation: "requireNotBanned", 299 - userId: user.did, 300 - error: error instanceof Error ? error.message : String(error), 301 - }); 302 - 303 - if (error instanceof Error && isDatabaseError(error)) { 304 - return c.json( 305 - { error: "Database temporarily unavailable. Please try again later." }, 306 - 503 307 - ); 308 - } 309 - 310 - // Unexpected errors - fail closed 311 - return c.json( 312 - { error: "Unable to verify permissions. Please try again later." }, 313 - 500 314 - ); 315 258 } 316 259 317 260 await next();
+37
apps/appview/src/middleware/require-not-banned.ts
··· 1 + import type { Context, Next } from "hono"; 2 + import type { AppContext } from "../lib/app-context.js"; 3 + import type { Variables } from "../types.js"; 4 + import { handleSecurityCheckError } from "../lib/route-errors.js"; 5 + import { getActiveBans } from "../routes/helpers.js"; 6 + 7 + /** 8 + * Middleware that checks if the authenticated user is banned. 9 + * 10 + * Must be used AFTER requireAuth (depends on c.get("user") being set). 11 + * Fails closed: if the ban check fails, the request is denied. 12 + * 13 + * Usage: 14 + * app.post("/api/topics", requireAuth(ctx), requireNotBanned(ctx), async (c) => { ... }); 15 + */ 16 + export function requireNotBanned(ctx: AppContext) { 17 + return async (c: Context<{ Variables: Variables }>, next: Next) => { 18 + const user = c.get("user"); 19 + if (!user) { 20 + return c.json({ error: "Authentication required" }, 401); 21 + } 22 + 23 + try { 24 + const bannedUsers = await getActiveBans(ctx.db, [user.did]); 25 + if (bannedUsers.has(user.did)) { 26 + return c.json({ error: "You are banned from this forum" }, 403); 27 + } 28 + } catch (error) { 29 + return handleSecurityCheckError(c, error, "Unable to verify ban status", { 30 + operation: `${c.req.method} ${c.req.path} - ban check`, 31 + userId: user.did, 32 + }); 33 + } 34 + 35 + await next(); 36 + }; 37 + }
+3 -3
apps/appview/src/routes/__tests__/admin.test.ts
··· 331 331 332 332 expect(res.status).toBe(503); 333 333 const data = await res.json(); 334 - expect(data.error).toContain("Unable to reach user's PDS"); 334 + expect(data.error).toContain("Unable to reach external service"); 335 335 }); 336 336 337 337 it("returns 500 when ForumAgent unavailable", async () => { ··· 365 365 permissions: ["space.atbb.permission.manageRoles"], 366 366 }); 367 367 368 - mockPutRecord.mockRejectedValue(new Error("Database connection lost")); 368 + mockPutRecord.mockRejectedValue(new Error("Unexpected write error")); 369 369 370 370 const res = await app.request("/api/admin/members/did:plc:test-target/role", { 371 371 method: "POST", ··· 377 377 378 378 expect(res.status).toBe(500); 379 379 const data = await res.json(); 380 - expect(data.error).toContain("server error"); 380 + expect(data.error).toContain("Failed to assign role"); 381 381 expect(data.error).not.toContain("PDS"); 382 382 }); 383 383 });
+6 -6
apps/appview/src/routes/__tests__/boards.test.ts
··· 116 116 expect(data.boards).toEqual([]); 117 117 }); 118 118 119 - it("returns 500 on database error", async () => { 119 + it("returns 503 on database error", async () => { 120 120 // Close the database connection to simulate a database error 121 121 await ctx.cleanup(); 122 122 cleanedUp = true; 123 123 124 124 const res = await app.request("/api/boards"); 125 - expect(res.status).toBe(500); 125 + expect(res.status).toBe(503); 126 126 127 127 const data = await res.json(); 128 128 expect(data.error).toBe( 129 - "Failed to retrieve boards. Please try again later." 129 + "Database temporarily unavailable. Please try again later." 130 130 ); 131 131 }); 132 132 ··· 508 508 expect(res.status).toBe(400); 509 509 }); 510 510 511 - it("returns 500 on database error", async () => { 511 + it("returns 503 on database error", async () => { 512 512 await ctx.cleanup(); 513 513 cleanedUp = true; 514 514 515 515 const res = await app.request("/api/boards/1"); 516 - expect(res.status).toBe(500); 516 + expect(res.status).toBe(503); 517 517 518 518 const data = await res.json(); 519 - expect(data.error).toBe("Failed to retrieve board. Please try again later."); 519 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 520 520 }); 521 521 }); 522 522 });
+3 -3
apps/appview/src/routes/__tests__/categories.test.ts
··· 283 283 expect(res.status).toBe(400); 284 284 }); 285 285 286 - it("returns 500 on database error", async () => { 286 + it("returns 503 on database error", async () => { 287 287 await ctx.cleanup(); 288 288 cleanedUp = true; 289 289 290 290 const res = await app.request("/1"); 291 - expect(res.status).toBe(500); 291 + expect(res.status).toBe(503); 292 292 293 293 const data = await res.json(); 294 - expect(data.error).toBe("Failed to retrieve category. Please try again later."); 294 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 295 295 }); 296 296 });
+36 -36
apps/appview/src/routes/__tests__/mod.test.ts
··· 564 564 565 565 expect(res.status).toBe(503); 566 566 const data = await res.json(); 567 - expect(data.error).toBe("Unable to reach Forum PDS. Please try again later."); 567 + expect(data.error).toBe("Unable to reach external service. Please try again later."); 568 568 }); 569 569 570 570 it("returns 500 for unexpected errors writing to PDS", async () => { ··· 590 590 }).onConflictDoNothing(); 591 591 592 592 // Mock putRecord to throw unexpected error (not network error) 593 - mockPutRecord.mockRejectedValueOnce(new Error("Database connection lost")); 593 + mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error")); 594 594 595 595 const res = await app.request("/api/mod/ban", { 596 596 method: "POST", ··· 603 603 604 604 expect(res.status).toBe(500); 605 605 const data = await res.json(); 606 - expect(data.error).toBe("Failed to record moderation action. Please contact support."); 606 + expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists."); 607 607 }); 608 608 609 - it("returns 500 when membership query fails (database error)", async () => { 609 + it("returns 503 when membership query fails (database error)", async () => { 610 610 // Mock database query to throw error 611 611 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 612 612 throw new Error("Database connection lost"); ··· 621 621 }), 622 622 }); 623 623 624 - expect(res.status).toBe(500); 624 + expect(res.status).toBe(503); 625 625 const data = await res.json(); 626 - expect(data.error).toBe("Failed to check user membership. Please try again later."); 626 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 627 627 628 628 // Restore original implementation 629 629 dbSelectSpy.mockRestore(); ··· 1104 1104 1105 1105 expect(res.status).toBe(503); 1106 1106 const data = await res.json(); 1107 - expect(data.error).toBe("Unable to reach Forum PDS. Please try again later."); 1107 + expect(data.error).toBe("Unable to reach external service. Please try again later."); 1108 1108 }); 1109 1109 1110 1110 it("returns 500 for unexpected errors writing to PDS", async () => { ··· 1154 1154 }); 1155 1155 1156 1156 // Mock putRecord to throw unexpected error (not network error) 1157 - mockPutRecord.mockRejectedValueOnce(new Error("Database connection lost")); 1157 + mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error")); 1158 1158 1159 1159 const res = await app.request(`/api/mod/ban/${targetDid}`, { 1160 1160 method: "DELETE", ··· 1166 1166 1167 1167 expect(res.status).toBe(500); 1168 1168 const data = await res.json(); 1169 - expect(data.error).toBe("Failed to record moderation action. Please contact support."); 1169 + expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists."); 1170 1170 }); 1171 1171 1172 - it("returns 500 when membership query fails (database error)", async () => { 1172 + it("returns 503 when membership query fails (database error)", async () => { 1173 1173 // Mock database query to throw error 1174 1174 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 1175 1175 throw new Error("Database connection lost"); ··· 1183 1183 }), 1184 1184 }); 1185 1185 1186 - expect(res.status).toBe(500); 1186 + expect(res.status).toBe(503); 1187 1187 const data = await res.json(); 1188 - expect(data.error).toBe("Failed to check user membership. Please try again later."); 1188 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 1189 1189 1190 1190 // Restore spy 1191 1191 dbSelectSpy.mockRestore(); ··· 1828 1828 1829 1829 expect(res.status).toBe(503); 1830 1830 const data = await res.json(); 1831 - expect(data.error).toBe("Unable to reach Forum PDS. Please try again later."); 1831 + expect(data.error).toBe("Unable to reach external service. Please try again later."); 1832 1832 }); 1833 1833 1834 1834 it("returns 500 for unexpected errors writing to PDS", async () => { ··· 1860 1860 }).returning(); 1861 1861 1862 1862 // Mock putRecord to throw unexpected error (not network error) 1863 - mockPutRecord.mockRejectedValueOnce(new Error("Database connection lost")); 1863 + mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error")); 1864 1864 1865 1865 const res = await app.request("/api/mod/lock", { 1866 1866 method: "POST", ··· 1873 1873 1874 1874 expect(res.status).toBe(500); 1875 1875 const data = await res.json(); 1876 - expect(data.error).toBe("Failed to record moderation action. Please contact support."); 1876 + expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists."); 1877 1877 }); 1878 1878 1879 - it("returns 500 when post query fails (database error)", async () => { 1879 + it("returns 503 when post query fails (database error)", async () => { 1880 1880 // Mock console.error to suppress error output during test 1881 1881 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 1882 1882 ··· 1894 1894 }), 1895 1895 }); 1896 1896 1897 - expect(res.status).toBe(500); 1897 + expect(res.status).toBe(503); 1898 1898 const data = await res.json(); 1899 - expect(data.error).toBe("Failed to check topic. Please try again later."); 1899 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 1900 1900 1901 1901 // Restore spies 1902 1902 consoleErrorSpy.mockRestore(); ··· 2307 2307 mockUser = { did: "did:plc:test-moderator" }; 2308 2308 }); 2309 2309 2310 - it("returns 500 when post query fails (database error)", async () => { 2310 + it("returns 503 when post query fails (database error)", async () => { 2311 2311 // Mock console.error to suppress error output during test 2312 2312 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 2313 2313 ··· 2323 2323 }), 2324 2324 }); 2325 2325 2326 - expect(res.status).toBe(500); 2326 + expect(res.status).toBe(503); 2327 2327 const data = await res.json(); 2328 - expect(data.error).toBe("Failed to check topic. Please try again later."); 2328 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 2329 2329 2330 2330 consoleErrorSpy.mockRestore(); 2331 2331 dbSelectSpy.mockRestore(); ··· 2551 2551 2552 2552 expect(res.status).toBe(503); 2553 2553 const data = await res.json(); 2554 - expect(data.error).toBe("Unable to reach Forum PDS. Please try again later."); 2554 + expect(data.error).toBe("Unable to reach external service. Please try again later."); 2555 2555 }); 2556 2556 2557 2557 it("returns 500 for unexpected errors writing to PDS", async () => { ··· 2607 2607 }); 2608 2608 2609 2609 // Mock putRecord to throw unexpected error (not network error) 2610 - mockPutRecord.mockRejectedValueOnce(new Error("Database connection lost")); 2610 + mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error")); 2611 2611 2612 2612 const res = await app.request(`/api/mod/lock/${topic.id}`, { 2613 2613 method: "DELETE", ··· 2619 2619 2620 2620 expect(res.status).toBe(500); 2621 2621 const data = await res.json(); 2622 - expect(data.error).toBe("Failed to record moderation action. Please contact support."); 2622 + expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists."); 2623 2623 }); 2624 2624 }); 2625 2625 }); ··· 3078 3078 mockUser = { did: "did:plc:test-moderator" }; 3079 3079 }); 3080 3080 3081 - it("returns 500 when post query fails (database error)", async () => { 3081 + it("returns 503 when post query fails (database error)", async () => { 3082 3082 // Mock console.error to suppress error output during test 3083 3083 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 3084 3084 ··· 3095 3095 }), 3096 3096 }); 3097 3097 3098 - expect(res.status).toBe(500); 3098 + expect(res.status).toBe(503); 3099 3099 const data = await res.json(); 3100 - expect(data.error).toBe("Failed to retrieve post. Please try again later."); 3100 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 3101 3101 3102 3102 consoleErrorSpy.mockRestore(); 3103 3103 dbSelectSpy.mockRestore(); ··· 3254 3254 3255 3255 expect(res.status).toBe(503); 3256 3256 const data = await res.json(); 3257 - expect(data.error).toBe("Unable to reach Forum PDS. Please try again later."); 3257 + expect(data.error).toBe("Unable to reach external service. Please try again later."); 3258 3258 }); 3259 3259 3260 3260 it("returns 500 for unexpected errors writing to PDS", async () => { ··· 3286 3286 }).returning(); 3287 3287 3288 3288 // Mock putRecord to throw unexpected error (not network error) 3289 - mockPutRecord.mockRejectedValueOnce(new Error("Database connection lost")); 3289 + mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error")); 3290 3290 3291 3291 const res = await app.request("/api/mod/hide", { 3292 3292 method: "POST", ··· 3299 3299 3300 3300 expect(res.status).toBe(500); 3301 3301 const data = await res.json(); 3302 - expect(data.error).toBe("Failed to record moderation action. Please contact support."); 3302 + expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists."); 3303 3303 }); 3304 3304 }); 3305 3305 }); ··· 3751 3751 mockUser = { did: "did:plc:test-moderator" }; 3752 3752 }); 3753 3753 3754 - it("returns 500 when post query fails (database error)", async () => { 3754 + it("returns 503 when post query fails (database error)", async () => { 3755 3755 // Mock console.error to suppress error output during test 3756 3756 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 3757 3757 ··· 3767 3767 }), 3768 3768 }); 3769 3769 3770 - expect(res.status).toBe(500); 3770 + expect(res.status).toBe(503); 3771 3771 const data = await res.json(); 3772 - expect(data.error).toBe("Failed to retrieve post. Please try again later."); 3772 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 3773 3773 3774 3774 consoleErrorSpy.mockRestore(); 3775 3775 dbSelectSpy.mockRestore(); ··· 3992 3992 3993 3993 expect(res.status).toBe(503); 3994 3994 const data = await res.json(); 3995 - expect(data.error).toBe("Unable to reach Forum PDS. Please try again later."); 3995 + expect(data.error).toBe("Unable to reach external service. Please try again later."); 3996 3996 }); 3997 3997 3998 3998 it("returns 500 for unexpected errors writing to PDS", async () => { ··· 4049 4049 }); 4050 4050 4051 4051 // Mock putRecord to throw unexpected error (not network error) 4052 - mockPutRecord.mockRejectedValueOnce(new Error("Database connection lost")); 4052 + mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error")); 4053 4053 4054 4054 const res = await app.request(`/api/mod/hide/${post.id}`, { 4055 4055 method: "DELETE", ··· 4059 4059 4060 4060 expect(res.status).toBe(500); 4061 4061 const data = await res.json(); 4062 - expect(data.error).toBe("Failed to record moderation action. Please contact support."); 4062 + expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists."); 4063 4063 }); 4064 4064 }); 4065 4065 });
+11 -11
apps/appview/src/routes/__tests__/posts.test.ts
··· 289 289 290 290 expect(res.status).toBe(503); 291 291 const data = await res.json(); 292 - expect(data.error).toContain("Unable to reach your PDS"); 292 + expect(data.error).toContain("Unable to reach external service"); 293 293 }); 294 294 295 295 it("returns 503 when DNS resolution fails (ENOTFOUND)", async () => { ··· 307 307 308 308 expect(res.status).toBe(503); 309 309 const data = await res.json(); 310 - expect(data.error).toContain("Unable to reach your PDS"); 310 + expect(data.error).toContain("Unable to reach external service"); 311 311 }); 312 312 313 313 it("returns 503 when request times out", async () => { ··· 325 325 326 326 expect(res.status).toBe(503); 327 327 const data = await res.json(); 328 - expect(data.error).toContain("Unable to reach your PDS"); 328 + expect(data.error).toContain("Unable to reach external service"); 329 329 }); 330 330 331 331 // Critical test coverage: PDS server errors (500) ··· 517 517 expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 518 518 519 519 expect(consoleErrorSpy).toHaveBeenCalledWith( 520 - "Failed to check ban status", 520 + "Unable to verify ban status", 521 521 expect.objectContaining({ 522 - operation: "requireNotBanned", 522 + operation: "POST /api/posts - ban check", 523 523 userId: mockUser.did, 524 524 error: "Database connection lost", 525 525 }) ··· 548 548 549 549 expect(res.status).toBe(500); 550 550 const data = await res.json(); 551 - expect(data.error).toBe("Unable to verify permissions. Please try again later."); 551 + expect(data.error).toBe("Unable to verify ban status. Please contact support if this persists."); 552 552 553 553 expect(consoleErrorSpy).toHaveBeenCalledWith( 554 - "Failed to check ban status", 554 + "Unable to verify ban status", 555 555 expect.objectContaining({ 556 - operation: "requireNotBanned", 556 + operation: "POST /api/posts - ban check", 557 557 userId: mockUser.did, 558 558 error: "Unexpected internal error", 559 559 }) ··· 588 588 589 589 // Verify CRITICAL error was logged (proves the re-throw path was executed) 590 590 expect(consoleErrorSpy).toHaveBeenCalledWith( 591 - "CRITICAL: Programming error in ban check", 591 + "CRITICAL: Programming error in POST /api/posts - ban check", 592 592 expect.objectContaining({ 593 - operation: "requireNotBanned", 593 + operation: "POST /api/posts - ban check", 594 594 userId: mockUser.did, 595 595 error: "Cannot read property 'has' of undefined", 596 596 stack: expect.any(String), ··· 599 599 600 600 // Verify the normal error path was NOT taken 601 601 expect(consoleErrorSpy).not.toHaveBeenCalledWith( 602 - "Failed to check ban status", 602 + "Unable to verify ban status", 603 603 expect.any(Object) 604 604 ); 605 605
+13 -13
apps/appview/src/routes/__tests__/topics.test.ts
··· 365 365 366 366 expect(res.status).toBe(503); 367 367 const data = await res.json(); 368 - expect(data.error).toContain("Unable to reach your PDS"); 368 + expect(data.error).toContain("Unable to reach external service"); 369 369 }); 370 370 371 371 it("returns 503 when PDS connection times out", async () => { ··· 379 379 380 380 expect(res.status).toBe(503); 381 381 const data = await res.json(); 382 - expect(data.error).toContain("Unable to reach your PDS"); 382 + expect(data.error).toContain("Unable to reach external service"); 383 383 }); 384 384 385 385 it("returns 503 when PDS connection refused (ECONNREFUSED)", async () => { ··· 393 393 394 394 expect(res.status).toBe(503); 395 395 const data = await res.json(); 396 - expect(data.error).toContain("Unable to reach your PDS"); 396 + expect(data.error).toContain("Unable to reach external service"); 397 397 }); 398 398 399 399 // Critical test coverage: PDS server errors (500) ··· 443 443 expect(res.status).toBe(500); 444 444 const data = await res.json(); 445 445 expect(data.error).toContain("Failed to create topic"); 446 - expect(data.error).toContain("report this issue"); 446 + expect(data.error).toContain("Please contact support"); 447 447 }); 448 448 449 449 it("POST /api/topics creates topic with board reference", async () => { ··· 956 956 expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 957 957 958 958 expect(consoleErrorSpy).toHaveBeenCalledWith( 959 - "Failed to check ban status", 959 + "Unable to verify ban status", 960 960 expect.objectContaining({ 961 - operation: "requireNotBanned", 961 + operation: "POST /api/topics - ban check", 962 962 userId: mockUser.did, 963 963 error: "Database connection lost", 964 964 }) ··· 991 991 992 992 expect(res.status).toBe(500); 993 993 const data = await res.json(); 994 - expect(data.error).toBe("Unable to verify permissions. Please try again later."); 994 + expect(data.error).toBe("Unable to verify ban status. Please contact support if this persists."); 995 995 996 996 expect(consoleErrorSpy).toHaveBeenCalledWith( 997 - "Failed to check ban status", 997 + "Unable to verify ban status", 998 998 expect.objectContaining({ 999 - operation: "requireNotBanned", 999 + operation: "POST /api/topics - ban check", 1000 1000 userId: mockUser.did, 1001 1001 error: "Unexpected internal error", 1002 1002 }) ··· 1035 1035 // Hono's default error handler returns 500 for uncaught throws 1036 1036 expect(res.status).toBe(500); 1037 1037 1038 - // Verify CRITICAL error was logged before re-throw (not "Failed to check ban status") 1038 + // Verify CRITICAL error was logged before re-throw (not "Unable to verify ban status") 1039 1039 expect(consoleErrorSpy).toHaveBeenCalledWith( 1040 - "CRITICAL: Programming error in ban check", 1040 + "CRITICAL: Programming error in POST /api/topics - ban check", 1041 1041 expect.objectContaining({ 1042 - operation: "requireNotBanned", 1042 + operation: "POST /api/topics - ban check", 1043 1043 userId: mockUser.did, 1044 1044 error: "Cannot read property 'includes' of undefined", 1045 1045 stack: expect.any(String), ··· 1048 1048 1049 1049 // Verify the normal error path was NOT taken (programming errors bypass normal logging) 1050 1050 expect(consoleErrorSpy).not.toHaveBeenCalledWith( 1051 - "Failed to check ban status", 1051 + "Unable to verify ban status", 1052 1052 expect.any(Object) 1053 1053 ); 1054 1054
+15 -56
apps/appview/src/routes/admin.ts
··· 5 5 import { requirePermission, getUserRole } from "../middleware/permissions.js"; 6 6 import { memberships, roles, users, forums } from "@atbb/db"; 7 7 import { eq, and, sql, asc } from "drizzle-orm"; 8 - import { isNetworkError, isProgrammingError } from "../lib/errors.js"; 8 + import { 9 + handleReadError, 10 + handleWriteError, 11 + safeParseJsonBody, 12 + getForumAgentOrError, 13 + } from "../lib/route-errors.js"; 9 14 10 15 export function createAdminRoutes(ctx: AppContext) { 11 16 const app = new Hono<{ Variables: Variables }>(); ··· 24 29 const user = c.get("user")!; 25 30 26 31 // Parse and validate request body 27 - let body: any; 28 - try { 29 - body = await c.req.json(); 30 - } catch { 31 - return c.json({ error: "Invalid JSON in request body" }, 400); 32 - } 32 + const { body, error: parseError } = await safeParseJsonBody(c); 33 + if (parseError) return parseError; 33 34 34 35 const { roleUri } = body; 35 36 ··· 102 103 } 103 104 104 105 // Get ForumAgent for PDS write operations 105 - if (!ctx.forumAgent) { 106 - return c.json({ 107 - error: "Forum agent not available. Server configuration issue.", 108 - }, 500); 109 - } 110 - 111 - const agent = ctx.forumAgent.getAgent(); 112 - if (!agent) { 113 - return c.json({ 114 - error: "Forum agent not authenticated. Please try again later.", 115 - }, 503); 116 - } 106 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/members/:did/role"); 107 + if (agentError) return agentError; 117 108 118 109 try { 119 110 // Update membership record on user's PDS using ForumAgent ··· 136 127 targetDid, 137 128 }); 138 129 } catch (error) { 139 - if (isProgrammingError(error)) throw error; 140 - console.error("Failed to assign role", { 130 + return handleWriteError(c, error, "Failed to assign role", { 141 131 operation: "POST /api/admin/members/:did/role", 142 132 targetDid, 143 133 roleUri, 144 - error: error instanceof Error ? error.message : String(error), 145 134 }); 146 - 147 - // Classify error: network errors (503) vs server errors (500) 148 - if (error instanceof Error && isNetworkError(error)) { 149 - return c.json({ 150 - error: "Unable to reach user's PDS to update role. Please try again later.", 151 - }, 503); 152 - } 153 - 154 - return c.json({ 155 - error: "Failed to assign role due to server error. Please contact support.", 156 - }, 500); 157 135 } 158 136 } catch (error) { 159 - if (isProgrammingError(error)) throw error; 160 - console.error("Database error during role assignment", { 137 + return handleReadError(c, error, "Failed to process role assignment", { 161 138 operation: "POST /api/admin/members/:did/role", 162 139 targetDid, 163 140 roleUri, 164 - error: error instanceof Error ? error.message : String(error), 165 141 }); 166 - return c.json({ error: "Server error. Please try again later." }, 500); 167 142 } 168 143 } 169 144 ); ··· 201 176 })), 202 177 }); 203 178 } catch (error) { 204 - console.error("Failed to list roles", { 179 + return handleReadError(c, error, "Failed to retrieve roles", { 205 180 operation: "GET /api/admin/roles", 206 - error: error instanceof Error ? error.message : String(error), 207 181 }); 208 - 209 - return c.json({ 210 - error: "Failed to retrieve roles. Please try again later.", 211 - }, 500); 212 182 } 213 183 } 214 184 ); ··· 252 222 })), 253 223 }); 254 224 } catch (error) { 255 - console.error("Failed to list members", { 225 + return handleReadError(c, error, "Failed to retrieve members", { 256 226 operation: "GET /api/admin/members", 257 - error: error instanceof Error ? error.message : String(error), 258 227 }); 259 - 260 - return c.json({ 261 - error: "Failed to retrieve members. Please try again later.", 262 - }, 500); 263 228 } 264 229 } 265 230 ); ··· 311 276 permissions: member.permissions || [], 312 277 }); 313 278 } catch (error) { 314 - if (isProgrammingError(error)) throw error; 315 - console.error("Failed to get current user membership", { 279 + return handleReadError(c, error, "Failed to retrieve your membership", { 316 280 operation: "GET /api/admin/members/me", 317 281 did: user.did, 318 - error: error instanceof Error ? error.message : String(error), 319 282 }); 320 - return c.json( 321 - { error: "Failed to retrieve your membership. Please try again later." }, 322 - 500 323 - ); 324 283 } 325 284 }); 326 285
+4 -21
apps/appview/src/routes/boards.ts
··· 3 3 import { boards, categories, posts, users } from "@atbb/db"; 4 4 import { asc, count, eq, and, desc, isNull } from "drizzle-orm"; 5 5 import { serializeBoard, parseBigIntParam, serializePost } from "./helpers.js"; 6 + import { handleReadError } from "../lib/route-errors.js"; 6 7 7 8 /** 8 9 * Factory function that creates board routes with access to app context. ··· 22 23 boards: allBoards.map(({ boards: board }) => serializeBoard(board)), 23 24 }); 24 25 } catch (error) { 25 - console.error("Failed to query boards", { 26 + return handleReadError(c, error, "Failed to retrieve boards", { 26 27 operation: "GET /api/boards", 27 - error: error instanceof Error ? error.message : String(error), 28 28 }); 29 - 30 - return c.json( 31 - { 32 - error: "Failed to retrieve boards. Please try again later.", 33 - }, 34 - 500 35 - ); 36 29 } 37 30 }) 38 31 .get("/:id", async (c) => { ··· 55 48 56 49 return c.json(serializeBoard(board)); 57 50 } catch (error) { 58 - console.error("Failed to fetch board by ID", { 51 + return handleReadError(c, error, "Failed to retrieve board", { 59 52 operation: "GET /api/boards/:id", 60 53 boardId: raw, 61 - error: error instanceof Error ? error.message : String(error), 62 54 }); 63 - return c.json({ error: "Failed to retrieve board. Please try again later." }, 500); 64 55 } 65 56 }) 66 57 .get("/:id/topics", async (c) => { ··· 124 115 limit, 125 116 }); 126 117 } catch (error) { 127 - console.error("Failed to query board topics", { 118 + return handleReadError(c, error, "Failed to retrieve topics", { 128 119 operation: "GET /api/boards/:id/topics", 129 120 boardId: id, 130 - error: error instanceof Error ? error.message : String(error), 131 121 }); 132 - 133 - return c.json( 134 - { 135 - error: "Failed to retrieve topics. Please try again later.", 136 - }, 137 - 500 138 - ); 139 122 } 140 123 }); 141 124 }
+4 -21
apps/appview/src/routes/categories.ts
··· 3 3 import { categories, boards } from "@atbb/db"; 4 4 import { eq, asc } from "drizzle-orm"; 5 5 import { serializeCategory, serializeBoard, parseBigIntParam } from "./helpers.js"; 6 + import { handleReadError } from "../lib/route-errors.js"; 6 7 7 8 /** 8 9 * Factory function that creates category routes with access to app context. ··· 27 28 categories: allCategories.map(serializeCategory), 28 29 }); 29 30 } catch (error) { 30 - console.error("Failed to query categories", { 31 + return handleReadError(c, error, "Failed to retrieve categories", { 31 32 operation: "GET /api/categories", 32 - error: error instanceof Error ? error.message : String(error), 33 33 }); 34 - 35 - return c.json( 36 - { 37 - error: "Failed to retrieve categories. Please try again later.", 38 - }, 39 - 500 40 - ); 41 34 } 42 35 }) 43 36 .get("/:id", async (c) => { ··· 60 53 61 54 return c.json(serializeCategory(category)); 62 55 } catch (error) { 63 - console.error("Failed to fetch category by ID", { 56 + return handleReadError(c, error, "Failed to retrieve category", { 64 57 operation: "GET /api/categories/:id", 65 58 categoryId: raw, 66 - error: error instanceof Error ? error.message : String(error), 67 59 }); 68 - return c.json({ error: "Failed to retrieve category. Please try again later." }, 500); 69 60 } 70 61 }) 71 62 .get("/:id/boards", async (c) => { ··· 99 90 boards: categoryBoards.map(serializeBoard), 100 91 }); 101 92 } catch (error) { 102 - console.error("Failed to query category boards", { 93 + return handleReadError(c, error, "Failed to retrieve boards", { 103 94 operation: "GET /api/categories/:id/boards", 104 95 categoryId: id, 105 - error: error instanceof Error ? error.message : String(error), 106 96 }); 107 - 108 - return c.json( 109 - { 110 - error: "Failed to retrieve boards. Please try again later.", 111 - }, 112 - 500 113 - ); 114 97 } 115 98 }); 116 99 }
+2 -9
apps/appview/src/routes/forum.ts
··· 3 3 import { forums } from "@atbb/db"; 4 4 import { eq } from "drizzle-orm"; 5 5 import { serializeForum } from "./helpers.js"; 6 + import { handleReadError } from "../lib/route-errors.js"; 6 7 7 8 /** 8 9 * Factory function that creates forum routes with access to app context. ··· 23 24 24 25 return c.json(serializeForum(forum)); 25 26 } catch (error) { 26 - console.error("Failed to query forum metadata", { 27 + return handleReadError(c, error, "Failed to retrieve forum metadata", { 27 28 operation: "GET /api/forum", 28 - error: error instanceof Error ? error.message : String(error), 29 29 }); 30 - 31 - return c.json( 32 - { 33 - error: "Failed to retrieve forum metadata. Please try again later.", 34 - }, 35 - 500 36 - ); 37 30 } 38 31 }); 39 32 }
+43 -273
apps/appview/src/routes/mod.ts
··· 4 4 import { TID } from "@atproto/common-web"; 5 5 import { requireAuth } from "../middleware/auth.js"; 6 6 import { requirePermission } from "../middleware/permissions.js"; 7 - import { isProgrammingError, isNetworkError } from "../lib/errors.js"; 7 + import { isProgrammingError } from "../lib/errors.js"; 8 + import { 9 + handleWriteError, 10 + handleReadError, 11 + safeParseJsonBody, 12 + getForumAgentOrError, 13 + } from "../lib/route-errors.js"; 8 14 import { parseBigIntParam } from "./helpers.js"; 9 15 import type { AppContext } from "../lib/app-context.js"; 10 16 import type { Variables } from "../types.js"; ··· 102 108 requirePermission(ctx, "space.atbb.permission.banUsers"), 103 109 async (c) => { 104 110 // Parse request body 105 - let body: any; 106 - try { 107 - body = await c.req.json(); 108 - } catch { 109 - return c.json({ error: "Invalid JSON in request body" }, 400); 110 - } 111 + const { body, error: parseError } = await safeParseJsonBody(c); 112 + if (parseError) return parseError; 111 113 112 114 const { targetDid, reason } = body; 113 115 ··· 134 136 return c.json({ error: "Target user not found" }, 404); 135 137 } 136 138 } catch (error) { 137 - console.error("Failed to query membership", { 139 + return handleReadError(c, error, "Failed to check user membership", { 138 140 operation: "POST /api/mod/ban", 139 141 targetDid, 140 - error: error instanceof Error ? error.message : String(error), 141 142 }); 142 - return c.json({ 143 - error: "Failed to check user membership. Please try again later.", 144 - }, 500); 145 143 } 146 144 147 145 // Check if user is already banned ··· 152 150 ); 153 151 154 152 if (isAlreadyBanned === true) { 155 - // User is already banned - return success without writing duplicate action 156 153 return c.json({ 157 154 success: true, 158 155 action: "space.atbb.modAction.ban", ··· 164 161 } 165 162 166 163 // Get ForumAgent 167 - if (!ctx.forumAgent) { 168 - console.error("CRITICAL: ForumAgent not available", { 169 - operation: "POST /api/mod/ban", 170 - forumDid: ctx.config.forumDid, 171 - }); 172 - return c.json({ 173 - error: "Forum agent not available. Server configuration issue.", 174 - }, 500); 175 - } 176 - 177 - const agent = ctx.forumAgent.getAgent(); 178 - if (!agent) { 179 - console.error("ForumAgent not authenticated", { 180 - operation: "POST /api/mod/ban", 181 - forumDid: ctx.config.forumDid, 182 - }); 183 - return c.json({ 184 - error: "Forum agent not authenticated. Please try again later.", 185 - }, 503); 186 - } 164 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/mod/ban"); 165 + if (agentError) return agentError; 187 166 188 167 // Write modAction record to Forum DID's PDS 189 168 const user = c.get("user")!; ··· 213 192 alreadyActive: false, 214 193 }); 215 194 } catch (error) { 216 - console.error("Failed to write ban modAction", { 195 + return handleWriteError(c, error, "Failed to record moderation action", { 217 196 operation: "POST /api/mod/ban", 218 197 moderatorDid: user.did, 219 198 targetDid, 220 199 forumDid: ctx.config.forumDid, 221 200 action: "space.atbb.modAction.ban", 222 - error: error instanceof Error ? error.message : String(error), 223 201 }); 224 - 225 - // Network errors = temporary (503) 226 - if (error instanceof Error && isNetworkError(error)) { 227 - return c.json({ 228 - error: "Unable to reach Forum PDS. Please try again later.", 229 - }, 503); 230 - } 231 - 232 - // All other errors = server bug (500) 233 - return c.json({ 234 - error: "Failed to record moderation action. Please contact support.", 235 - }, 500); 236 202 } 237 203 } 238 204 ); ··· 252 218 } 253 219 254 220 // Parse request body 255 - let body: any; 256 - try { 257 - body = await c.req.json(); 258 - } catch { 259 - return c.json({ error: "Invalid JSON in request body" }, 400); 260 - } 221 + const { body, error: parseError } = await safeParseJsonBody(c); 222 + if (parseError) return parseError; 261 223 262 224 const { reason } = body; 263 225 ··· 279 241 return c.json({ error: "Target user not found" }, 404); 280 242 } 281 243 } catch (error) { 282 - console.error("Failed to query membership", { 244 + return handleReadError(c, error, "Failed to check user membership", { 283 245 operation: "DELETE /api/mod/ban/:did", 284 246 targetDid, 285 - error: error instanceof Error ? error.message : String(error), 286 247 }); 287 - return c.json({ 288 - error: "Failed to check user membership. Please try again later.", 289 - }, 500); 290 248 } 291 249 292 250 // Check if user is already unbanned (not banned) ··· 309 267 } 310 268 311 269 // Get ForumAgent 312 - if (!ctx.forumAgent) { 313 - console.error("CRITICAL: ForumAgent not available", { 314 - operation: "DELETE /api/mod/ban/:did", 315 - forumDid: ctx.config.forumDid, 316 - }); 317 - return c.json({ 318 - error: "Forum agent not available. Server configuration issue.", 319 - }, 500); 320 - } 321 - 322 - const agent = ctx.forumAgent.getAgent(); 323 - if (!agent) { 324 - console.error("ForumAgent not authenticated", { 325 - operation: "DELETE /api/mod/ban/:did", 326 - forumDid: ctx.config.forumDid, 327 - }); 328 - return c.json({ 329 - error: "Forum agent not authenticated. Please try again later.", 330 - }, 503); 331 - } 270 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/mod/ban/:did"); 271 + if (agentError) return agentError; 332 272 333 273 // Write unban modAction record to Forum DID's PDS 334 274 const user = c.get("user")!; ··· 358 298 alreadyActive: false, 359 299 }); 360 300 } catch (error) { 361 - console.error("Failed to write unban modAction", { 301 + return handleWriteError(c, error, "Failed to record moderation action", { 362 302 operation: "DELETE /api/mod/ban/:did", 363 303 moderatorDid: user.did, 364 304 targetDid, 365 305 forumDid: ctx.config.forumDid, 366 306 action: "space.atbb.modAction.unban", 367 - error: error instanceof Error ? error.message : String(error), 368 307 }); 369 - 370 - // Network errors = temporary (503) 371 - if (error instanceof Error && isNetworkError(error)) { 372 - return c.json({ 373 - error: "Unable to reach Forum PDS. Please try again later.", 374 - }, 503); 375 - } 376 - 377 - // All other errors = server bug (500) 378 - return c.json({ 379 - error: "Failed to record moderation action. Please contact support.", 380 - }, 500); 381 308 } 382 309 } 383 310 ); ··· 389 316 requirePermission(ctx, "space.atbb.permission.lockTopics"), 390 317 async (c) => { 391 318 // Parse request body 392 - let body: any; 393 - try { 394 - body = await c.req.json(); 395 - } catch { 396 - return c.json({ error: "Invalid JSON in request body" }, 400); 397 - } 319 + const { body, error: parseError } = await safeParseJsonBody(c); 320 + if (parseError) return parseError; 398 321 399 322 const { topicId, reason } = body; 400 323 ··· 429 352 430 353 topic = result; 431 354 } catch (error) { 432 - console.error("Failed to query topic", { 355 + return handleReadError(c, error, "Failed to check topic", { 433 356 operation: "POST /api/mod/lock", 434 357 topicId, 435 - error: error instanceof Error ? error.message : String(error), 436 358 }); 437 - return c.json({ 438 - error: "Failed to check topic. Please try again later.", 439 - }, 500); 440 359 } 441 360 442 361 // Validate it's a root post (topic, not reply) ··· 455 374 ); 456 375 457 376 if (isAlreadyLocked === true) { 458 - // Topic is already locked - return success without writing duplicate action 459 377 return c.json({ 460 378 success: true, 461 379 action: "space.atbb.modAction.lock", ··· 467 385 } 468 386 469 387 // Get ForumAgent 470 - if (!ctx.forumAgent) { 471 - console.error("CRITICAL: ForumAgent not available", { 472 - operation: "POST /api/mod/lock", 473 - forumDid: ctx.config.forumDid, 474 - }); 475 - return c.json({ 476 - error: "Forum agent not available. Server configuration issue.", 477 - }, 500); 478 - } 479 - 480 - const agent = ctx.forumAgent.getAgent(); 481 - if (!agent) { 482 - console.error("ForumAgent not authenticated", { 483 - operation: "POST /api/mod/lock", 484 - forumDid: ctx.config.forumDid, 485 - }); 486 - return c.json({ 487 - error: "Forum agent not authenticated. Please try again later.", 488 - }, 503); 489 - } 388 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/mod/lock"); 389 + if (agentError) return agentError; 490 390 491 391 // Write modAction record to Forum DID's PDS 492 392 const user = c.get("user")!; ··· 521 421 alreadyActive: false, 522 422 }); 523 423 } catch (error) { 524 - console.error("Failed to write lock modAction", { 424 + return handleWriteError(c, error, "Failed to record moderation action", { 525 425 operation: "POST /api/mod/lock", 526 426 moderatorDid: user.did, 527 427 topicId, 528 428 postUri, 529 429 forumDid: ctx.config.forumDid, 530 430 action: "space.atbb.modAction.lock", 531 - error: error instanceof Error ? error.message : String(error), 532 431 }); 533 - 534 - // Network errors = temporary (503) 535 - if (error instanceof Error && isNetworkError(error)) { 536 - return c.json({ 537 - error: "Unable to reach Forum PDS. Please try again later.", 538 - }, 503); 539 - } 540 - 541 - // All other errors = server bug (500) 542 - return c.json({ 543 - error: "Failed to record moderation action. Please contact support.", 544 - }, 500); 545 432 } 546 433 } 547 434 ); ··· 562 449 } 563 450 564 451 // Parse request body 565 - let body: any; 566 - try { 567 - body = await c.req.json(); 568 - } catch { 569 - return c.json({ error: "Invalid JSON in request body" }, 400); 570 - } 452 + const { body, error: parseError } = await safeParseJsonBody(c); 453 + if (parseError) return parseError; 571 454 572 455 const { reason } = body; 573 456 ··· 592 475 593 476 topic = result; 594 477 } catch (error) { 595 - console.error("Failed to query topic", { 478 + return handleReadError(c, error, "Failed to check topic", { 596 479 operation: "DELETE /api/mod/lock/:topicId", 597 480 topicId: topicIdParam, 598 - error: error instanceof Error ? error.message : String(error), 599 481 }); 600 - return c.json({ 601 - error: "Failed to check topic. Please try again later.", 602 - }, 500); 603 482 } 604 483 605 484 // Validate it's a root post (topic, not reply) ··· 630 509 } 631 510 632 511 // Get ForumAgent 633 - if (!ctx.forumAgent) { 634 - console.error("CRITICAL: ForumAgent not available", { 635 - operation: "DELETE /api/mod/lock/:topicId", 636 - forumDid: ctx.config.forumDid, 637 - }); 638 - return c.json({ 639 - error: "Forum agent not available. Server configuration issue.", 640 - }, 500); 641 - } 642 - 643 - const agent = ctx.forumAgent.getAgent(); 644 - if (!agent) { 645 - console.error("ForumAgent not authenticated", { 646 - operation: "DELETE /api/mod/lock/:topicId", 647 - forumDid: ctx.config.forumDid, 648 - }); 649 - return c.json({ 650 - error: "Forum agent not authenticated. Please try again later.", 651 - }, 503); 652 - } 512 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/mod/lock/:topicId"); 513 + if (agentError) return agentError; 653 514 654 515 // Write unlock modAction record to Forum DID's PDS 655 516 const user = c.get("user")!; ··· 684 545 alreadyActive: false, 685 546 }); 686 547 } catch (error) { 687 - console.error("Failed to write unlock modAction", { 548 + return handleWriteError(c, error, "Failed to record moderation action", { 688 549 operation: "DELETE /api/mod/lock/:topicId", 689 550 moderatorDid: user.did, 690 551 topicId: topicIdParam, 691 552 postUri, 692 553 forumDid: ctx.config.forumDid, 693 554 action: "space.atbb.modAction.unlock", 694 - error: error instanceof Error ? error.message : String(error), 695 555 }); 696 - 697 - // Network errors = temporary (503) 698 - if (error instanceof Error && isNetworkError(error)) { 699 - return c.json({ 700 - error: "Unable to reach Forum PDS. Please try again later.", 701 - }, 503); 702 - } 703 - 704 - // All other errors = server bug (500) 705 - return c.json({ 706 - error: "Failed to record moderation action. Please contact support.", 707 - }, 500); 708 556 } 709 557 } 710 558 ); ··· 724 572 const user = c.get("user")!; 725 573 726 574 // Parse request body 727 - let body: any; 728 - try { 729 - body = await c.req.json(); 730 - } catch { 731 - return c.json({ error: "Invalid JSON in request body" }, 400); 732 - } 575 + const { body, error: parseError } = await safeParseJsonBody(c); 576 + if (parseError) return parseError; 733 577 734 578 const { postId, reason } = body; 735 579 ··· 764 608 765 609 post = result; 766 610 } catch (error) { 767 - console.error("Failed to query post", { 611 + return handleReadError(c, error, "Failed to retrieve post", { 768 612 operation: "POST /api/mod/hide", 769 613 postId, 770 - error: error instanceof Error ? error.message : String(error), 771 614 }); 772 - return c.json({ 773 - error: "Failed to retrieve post. Please try again later.", 774 - }, 500); 775 615 } 776 616 777 617 const postUri = `at://${post.did}/space.atbb.post/${post.rkey}`; ··· 796 636 } 797 637 798 638 // Get ForumAgent 799 - if (!ctx.forumAgent) { 800 - console.error("CRITICAL: ForumAgent not available", { 801 - operation: "POST /api/mod/hide", 802 - forumDid: ctx.config.forumDid, 803 - }); 804 - return c.json({ 805 - error: "Forum agent not available. Server configuration issue.", 806 - }, 500); 807 - } 808 - 809 - const agent = ctx.forumAgent.getAgent(); 810 - if (!agent) { 811 - console.error("ForumAgent not authenticated", { 812 - operation: "POST /api/mod/hide", 813 - forumDid: ctx.config.forumDid, 814 - }); 815 - return c.json({ 816 - error: "Forum agent not authenticated. Please try again later.", 817 - }, 503); 818 - } 639 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/mod/hide"); 640 + if (agentError) return agentError; 819 641 820 642 // Write hide modAction record (action type is "delete" per lexicon) 821 643 try { ··· 849 671 alreadyActive: false, 850 672 }, 200); 851 673 } catch (error) { 852 - console.error("Failed to write hide modAction", { 674 + return handleWriteError(c, error, "Failed to record moderation action", { 853 675 operation: "POST /api/mod/hide", 854 676 moderatorDid: user.did, 855 677 postId, 856 678 postUri, 857 679 forumDid: ctx.config.forumDid, 858 680 action: "space.atbb.modAction.delete", 859 - error: error instanceof Error ? error.message : String(error), 860 681 }); 861 - 862 - // Network errors = temporary (503) 863 - if (error instanceof Error && isNetworkError(error)) { 864 - return c.json({ 865 - error: "Unable to reach Forum PDS. Please try again later.", 866 - }, 503); 867 - } 868 - 869 - // All other errors = server bug (500) 870 - return c.json({ 871 - error: "Failed to record moderation action. Please contact support.", 872 - }, 500); 873 682 } 874 683 } 875 684 ); ··· 895 704 } 896 705 897 706 // Parse request body 898 - let body: any; 899 - try { 900 - body = await c.req.json(); 901 - } catch { 902 - return c.json({ error: "Invalid JSON in request body" }, 400); 903 - } 707 + const { body, error: parseError } = await safeParseJsonBody(c); 708 + if (parseError) return parseError; 904 709 905 710 const { reason } = body; 906 711 ··· 925 730 926 731 post = result; 927 732 } catch (error) { 928 - console.error("Failed to query post", { 733 + return handleReadError(c, error, "Failed to retrieve post", { 929 734 operation: "DELETE /api/mod/hide/:postId", 930 735 postId: postIdParam, 931 - error: error instanceof Error ? error.message : String(error), 932 736 }); 933 - return c.json({ 934 - error: "Failed to retrieve post. Please try again later.", 935 - }, 500); 936 737 } 937 738 938 739 const postUri = `at://${post.did}/space.atbb.post/${post.rkey}`; ··· 957 758 } 958 759 959 760 // Get ForumAgent 960 - if (!ctx.forumAgent) { 961 - console.error("CRITICAL: ForumAgent not available", { 962 - operation: "DELETE /api/mod/hide/:postId", 963 - forumDid: ctx.config.forumDid, 964 - }); 965 - return c.json({ 966 - error: "Forum agent not available. Server configuration issue.", 967 - }, 500); 968 - } 969 - 970 - const agent = ctx.forumAgent.getAgent(); 971 - if (!agent) { 972 - console.error("ForumAgent not authenticated", { 973 - operation: "DELETE /api/mod/hide/:postId", 974 - forumDid: ctx.config.forumDid, 975 - }); 976 - return c.json({ 977 - error: "Forum agent not authenticated. Please try again later.", 978 - }, 503); 979 - } 761 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/mod/hide/:postId"); 762 + if (agentError) return agentError; 980 763 981 764 // Write unhide modAction record 982 765 // Uses "undelete" action type for reversal (hide→unhide toggle) ··· 1011 794 alreadyActive: false, 1012 795 }, 200); 1013 796 } catch (error) { 1014 - console.error("Failed to write unhide modAction", { 797 + return handleWriteError(c, error, "Failed to record moderation action", { 1015 798 operation: "DELETE /api/mod/hide/:postId", 1016 799 moderatorDid: user.did, 1017 800 postId: postIdParam, 1018 801 postUri, 1019 802 forumDid: ctx.config.forumDid, 1020 803 action: "space.atbb.modAction.undelete", 1021 - error: error instanceof Error ? error.message : String(error), 1022 804 }); 1023 - 1024 - // Network errors = temporary (503) 1025 - if (error instanceof Error && isNetworkError(error)) { 1026 - return c.json({ 1027 - error: "Unable to reach Forum PDS. Please try again later.", 1028 - }, 503); 1029 - } 1030 - 1031 - // All other errors = server bug (500) 1032 - return c.json({ 1033 - error: "Failed to record moderation action. Please contact support.", 1034 - }, 500); 1035 805 } 1036 806 } 1037 807 );
+9 -74
apps/appview/src/routes/posts.ts
··· 3 3 import type { AppContext } from "../lib/app-context.js"; 4 4 import type { Variables } from "../types.js"; 5 5 import { requireAuth } from "../middleware/auth.js"; 6 - import { requirePermission, requireNotBanned } from "../middleware/permissions.js"; 7 - import { isProgrammingError, isNetworkError, isDatabaseError } from "../lib/errors.js"; 6 + import { requirePermission } from "../middleware/permissions.js"; 7 + import { requireNotBanned } from "../middleware/require-not-banned.js"; 8 + import { handleWriteError, handleSecurityCheckError, safeParseJsonBody } from "../lib/route-errors.js"; 8 9 import { 9 10 validatePostText, 10 11 parseBigIntParam, ··· 14 15 } from "./helpers.js"; 15 16 16 17 export function createPostsRoutes(ctx: AppContext) { 18 + // Ban check runs before permission check so banned users receive "You are banned" 19 + // rather than a generic "Permission denied" response. 17 20 return new Hono<{ Variables: Variables }>().post("/", requireAuth(ctx), requireNotBanned(ctx), requirePermission(ctx, "space.atbb.permission.createPosts"), async (c) => { 18 - // user is guaranteed to exist after requireAuth, requireNotBanned, and requirePermission middleware 19 21 const user = c.get("user")!; 20 22 21 23 // Parse and validate request body 22 - let body: any; 23 - try { 24 - body = await c.req.json(); 25 - } catch { 26 - return c.json({ error: "Invalid JSON in request body" }, 400); 27 - } 24 + const { body, error: parseError } = await safeParseJsonBody(c); 25 + if (parseError) return parseError; 28 26 29 27 const { text, rootPostId: rootIdStr, parentPostId: parentIdStr } = body; 30 28 ··· 54 52 return c.json({ error: "This topic is locked and not accepting new replies" }, 403); 55 53 } 56 54 } catch (error) { 57 - if (isProgrammingError(error)) { 58 - console.error("CRITICAL: Programming error in lock check", { 59 - operation: "POST /api/posts - lock check", 60 - userId: user.did, 61 - rootId: rootIdStr, 62 - error: error instanceof Error ? error.message : String(error), 63 - stack: error instanceof Error ? error.stack : undefined, 64 - }); 65 - throw error; 66 - } 67 - 68 - console.error("Failed to check topic lock status", { 55 + return handleSecurityCheckError(c, error, "Unable to verify topic status", { 69 56 operation: "POST /api/posts - lock check", 70 57 userId: user.did, 71 58 rootId: rootIdStr, 72 - error: error instanceof Error ? error.message : String(error), 73 59 }); 74 - 75 - if (error instanceof Error && isDatabaseError(error)) { 76 - return c.json( 77 - { error: "Database temporarily unavailable. Please try again later." }, 78 - 503 79 - ); 80 - } 81 - 82 - // Fail closed: if we can't verify lock status, deny the write 83 - return c.json( 84 - { error: "Unable to verify topic status. Please try again later." }, 85 - 500 86 - ); 87 60 } 88 61 89 62 try { ··· 147 120 201 148 121 ); 149 122 } catch (error) { 150 - // Re-throw programming bugs (don't catch TypeError, ReferenceError) 151 - if (isProgrammingError(error)) { 152 - console.error("CRITICAL: Programming error in POST /api/posts", { 153 - operation: "POST /api/posts", 154 - userId: user.did, 155 - rootId: rootIdStr, 156 - parentId: parentIdStr, 157 - error: error instanceof Error ? error.message : String(error), 158 - stack: error instanceof Error ? error.stack : undefined, 159 - }); 160 - throw error; // Let global error handler catch it 161 - } 162 - 163 - console.error("Failed to create post", { 123 + return handleWriteError(c, error, "Failed to create post", { 164 124 operation: "POST /api/posts", 165 125 userId: user.did, 166 126 rootId: rootIdStr, 167 127 parentId: parentIdStr, 168 - error: error instanceof Error ? error.message : String(error), 169 128 }); 170 - 171 - // Distinguish network errors from server errors 172 - if (error instanceof Error && isNetworkError(error)) { 173 - return c.json( 174 - { 175 - error: "Unable to reach your PDS. Please try again later.", 176 - }, 177 - 503 178 - ); 179 - } 180 - 181 - if (error instanceof Error && isDatabaseError(error)) { 182 - return c.json( 183 - { error: "Database temporarily unavailable. Please try again later." }, 184 - 503 185 - ); 186 - } 187 - 188 - return c.json( 189 - { 190 - error: "Failed to create post. Please try again later.", 191 - }, 192 - 500 193 - ); 194 129 } 195 130 }); 196 131 }
+13 -47
apps/appview/src/routes/topics.ts
··· 5 5 import { eq, and, asc } from "drizzle-orm"; 6 6 import { TID } from "@atproto/common-web"; 7 7 import { requireAuth } from "../middleware/auth.js"; 8 - import { requirePermission, requireNotBanned } from "../middleware/permissions.js"; 8 + import { requirePermission } from "../middleware/permissions.js"; 9 + import { requireNotBanned } from "../middleware/require-not-banned.js"; 9 10 import { parseAtUri } from "../lib/at-uri.js"; 10 - import { isProgrammingError, isNetworkError } from "../lib/errors.js"; 11 + import { handleReadError, handleWriteError, safeParseJsonBody } from "../lib/route-errors.js"; 12 + import { isProgrammingError } from "../lib/errors.js"; 11 13 import { 12 14 parseBigIntParam, 13 15 serializePost, ··· 71 73 try { 72 74 bannedUsers = await getActiveBans(ctx.db, allUserDids); 73 75 } catch (error) { 76 + if (isProgrammingError(error)) throw error; 74 77 console.error("Failed to query bans for topic view - showing all replies", { 75 78 operation: "GET /api/topics/:id - ban check", 76 79 topicId: id, ··· 84 87 try { 85 88 hiddenPosts = await getHiddenPosts(ctx.db, allPostIds); 86 89 } catch (error) { 90 + if (isProgrammingError(error)) throw error; 87 91 console.error("Failed to query hidden posts for topic view - showing all replies", { 88 92 operation: "GET /api/topics/:id - hidden posts", 89 93 topicId: id, ··· 101 105 try { 102 106 modStatus = await getTopicModStatus(ctx.db, topicId); 103 107 } catch (error) { 108 + if (isProgrammingError(error)) throw error; 104 109 console.error("Failed to query topic mod status - showing as unlocked", { 105 110 operation: "GET /api/topics/:id - mod status", 106 111 topicId: id, ··· 120 125 ), 121 126 }); 122 127 } catch (error) { 123 - console.error("Failed to query topic", { 128 + return handleReadError(c, error, "Failed to retrieve topic", { 124 129 operation: "GET /api/topics/:id", 125 130 topicId: id, 126 - error: error instanceof Error ? error.message : String(error), 127 131 }); 128 - 129 - return c.json( 130 - { 131 - error: "Failed to retrieve topic. Please try again later.", 132 - }, 133 - 500 134 - ); 135 132 } 136 133 }) 134 + // Ban check runs before permission check so banned users receive "You are banned" 135 + // rather than a generic "Permission denied" response. 137 136 .post("/", requireAuth(ctx), requireNotBanned(ctx), requirePermission(ctx, "space.atbb.permission.createTopics"), async (c) => { 138 137 // user is guaranteed to exist after requireAuth, requireNotBanned, and requirePermission middleware 139 138 const user = c.get("user")!; 140 139 141 140 // Parse and validate request body 142 - let body: any; 143 - try { 144 - body = await c.req.json(); 145 - } catch { 146 - return c.json({ error: "Invalid JSON in request body" }, 400); 147 - } 141 + const { body, error: parseError } = await safeParseJsonBody(c); 142 + if (parseError) return parseError; 148 143 149 144 const { title, text, boardUri } = body; 150 145 ··· 282 277 201 283 278 ); 284 279 } catch (error) { 285 - // Re-throw programming bugs (don't catch TypeError, ReferenceError) 286 - if (isProgrammingError(error)) { 287 - console.error("CRITICAL: Programming error in POST /api/topics", { 288 - operation: "POST /api/topics", 289 - userId: user.did, 290 - error: error instanceof Error ? error.message : String(error), 291 - stack: error instanceof Error ? error.stack : undefined, 292 - }); 293 - throw error; // Let global error handler catch it 294 - } 295 - 296 - console.error("Failed to create topic", { 280 + return handleWriteError(c, error, "Failed to create topic", { 297 281 operation: "POST /api/topics", 298 282 userId: user.did, 299 - error: error instanceof Error ? error.message : String(error), 300 283 }); 301 - 302 - if (error instanceof Error && isNetworkError(error)) { 303 - return c.json( 304 - { 305 - error: "Unable to reach your PDS. Please try again later.", 306 - }, 307 - 503 308 - ); 309 - } 310 - 311 - // Unexpected errors - may indicate bugs, should be investigated 312 - return c.json( 313 - { 314 - error: "Failed to create topic. Please report this issue if it persists.", 315 - }, 316 - 500 317 - ); 318 284 } 319 285 }); 320 286 }