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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at e749db1438a06bfdd8a9d9095f4d63a36cf9bf73 274 lines 8.1 kB view raw
1import type { Context } from "hono"; 2import type { AtpAgent } from "@atproto/api"; 3import type { AppContext } from "./app-context.js"; 4import type { Logger } from "@atbb/logger"; 5import { isProgrammingError, isNetworkError, isDatabaseError } from "./errors.js"; 6 7/** 8 * Structured context for error logging in route handlers. 9 */ 10interface ErrorContext { 11 operation: string; 12 logger: Logger; 13 [key: string]: unknown; 14} 15 16/** 17 * Format an error for structured logging. 18 * Extracts message and optionally stack from Error instances. 19 */ 20function formatError(error: unknown): { message: string; stack?: string } { 21 if (error instanceof Error) { 22 return { message: error.message, stack: error.stack }; 23 } 24 return { message: String(error) }; 25} 26 27/** 28 * Handle errors in read-path route handlers (GET endpoints). 29 * 30 * 1. Re-throws programming errors (TypeError, ReferenceError, SyntaxError) 31 * 2. Logs the error with structured context 32 * 3. Classifies the error and returns the appropriate HTTP status: 33 * - 503 for network errors (external service unreachable, user should retry) 34 * - 503 for database errors (temporary, user should retry) 35 * - 500 for unexpected errors (server bug, needs investigation) 36 * 37 * @param c - Hono context 38 * @param error - The caught error 39 * @param userMessage - User-facing error message (e.g., "Failed to retrieve forum metadata") 40 * @param ctx - Structured logging context 41 */ 42export function handleReadError( 43 c: Context, 44 error: unknown, 45 userMessage: string, 46 ctx: ErrorContext 47): Response { 48 if (isProgrammingError(error)) { 49 const { message, stack } = formatError(error); 50 ctx.logger.error(`CRITICAL: Programming error in ${ctx.operation}`, { 51 ...ctx, 52 error: message, 53 stack, 54 }); 55 throw error; 56 } 57 58 ctx.logger.error(userMessage, { 59 ...ctx, 60 error: formatError(error).message, 61 }); 62 63 if (error instanceof Error && isNetworkError(error)) { 64 return c.json( 65 { error: "Unable to reach external service. Please try again later." }, 66 503 67 ) as unknown as Response; 68 } 69 70 if (error instanceof Error && isDatabaseError(error)) { 71 return c.json( 72 { error: "Database temporarily unavailable. Please try again later." }, 73 503 74 ) as unknown as Response; 75 } 76 77 return c.json( 78 { error: `${userMessage}. Please contact support if this persists.` }, 79 500 80 ) as unknown as Response; 81} 82 83/** 84 * Handle errors in write-path route handlers (POST/PUT/DELETE endpoints). 85 * 86 * 1. Re-throws programming errors (TypeError, ReferenceError, SyntaxError) 87 * 2. Logs the error with structured context 88 * 3. Classifies the error and returns the appropriate HTTP status: 89 * - 503 for network errors (PDS unreachable, user should retry) 90 * - 503 for database errors (temporary, user should retry) 91 * - 500 for unexpected errors (server bug, needs investigation) 92 * 93 * @param c - Hono context 94 * @param error - The caught error 95 * @param userMessage - User-facing error message (e.g., "Failed to create topic") 96 * @param ctx - Structured logging context 97 */ 98export function handleWriteError( 99 c: Context, 100 error: unknown, 101 userMessage: string, 102 ctx: ErrorContext 103): Response { 104 if (isProgrammingError(error)) { 105 const { message, stack } = formatError(error); 106 ctx.logger.error(`CRITICAL: Programming error in ${ctx.operation}`, { 107 ...ctx, 108 error: message, 109 stack, 110 }); 111 throw error; 112 } 113 114 ctx.logger.error(userMessage, { 115 ...ctx, 116 error: formatError(error).message, 117 }); 118 119 if (error instanceof Error && isNetworkError(error)) { 120 return c.json( 121 { error: "Unable to reach external service. Please try again later." }, 122 503 123 ) as unknown as Response; 124 } 125 126 if (error instanceof Error && isDatabaseError(error)) { 127 return c.json( 128 { error: "Database temporarily unavailable. Please try again later." }, 129 503 130 ) as unknown as Response; 131 } 132 133 return c.json( 134 { error: `${userMessage}. Please contact support if this persists.` }, 135 500 136 ) as unknown as Response; 137} 138 139/** 140 * Handle errors in security-critical checks (ban check, lock check, permission verification). 141 * 142 * Fail-closed: returns an error response that denies the operation. 143 * 144 * 1. Re-throws programming errors 145 * 2. Returns 503 for network errors (external service unreachable, user should retry) 146 * 3. Returns 503 for database errors (temporary, user should retry) 147 * 4. Returns 500 for unexpected errors (denying access) 148 * 149 * @param c - Hono context 150 * @param error - The caught error 151 * @param userMessage - User-facing error message (e.g., "Unable to verify permissions") 152 * @param ctx - Structured logging context 153 */ 154export function handleSecurityCheckError( 155 c: Context, 156 error: unknown, 157 userMessage: string, 158 ctx: ErrorContext 159): Response { 160 if (isProgrammingError(error)) { 161 const { message, stack } = formatError(error); 162 ctx.logger.error(`CRITICAL: Programming error in ${ctx.operation}`, { 163 ...ctx, 164 error: message, 165 stack, 166 }); 167 throw error; 168 } 169 170 ctx.logger.error(userMessage, { 171 ...ctx, 172 error: formatError(error).message, 173 }); 174 175 if (error instanceof Error && isNetworkError(error)) { 176 return c.json( 177 { error: "Unable to reach external service. Please try again later." }, 178 503 179 ) as unknown as Response; 180 } 181 182 if (error instanceof Error && isDatabaseError(error)) { 183 return c.json( 184 { error: "Database temporarily unavailable. Please try again later." }, 185 503 186 ) as unknown as Response; 187 } 188 189 return c.json( 190 { error: `${userMessage}. Please contact support if this persists.` }, 191 500 192 ) as unknown as Response; 193} 194 195/** 196 * Get the ForumAgent's authenticated AtpAgent, or return an error response. 197 * 198 * Checks both that ForumAgent exists (server configuration) and that it's 199 * authenticated (has valid credentials). Returns appropriate error responses: 200 * - 500 if ForumAgent is not configured (server misconfiguration) 201 * - 503 if ForumAgent is not authenticated (temporary, should retry) 202 * 203 * Usage: 204 * ```typescript 205 * const { agent, error } = getForumAgentOrError(ctx, c, "POST /api/mod/ban"); 206 * if (error) return error; 207 * // agent is guaranteed to be non-null here 208 * ``` 209 */ 210export function getForumAgentOrError( 211 appCtx: AppContext, 212 c: Context, 213 operation: string 214): { agent: AtpAgent; error: null } | { agent: null; error: Response } { 215 if (!appCtx.forumAgent) { 216 appCtx.logger.error("CRITICAL: ForumAgent not available", { 217 operation, 218 forumDid: appCtx.config.forumDid, 219 }); 220 return { 221 agent: null, 222 error: c.json({ 223 error: "Forum agent not available. Server configuration issue.", 224 }, 500) as unknown as Response, 225 }; 226 } 227 228 const agent = appCtx.forumAgent.getAgent(); 229 if (!agent) { 230 appCtx.logger.error("ForumAgent not authenticated", { 231 operation, 232 forumDid: appCtx.config.forumDid, 233 }); 234 return { 235 agent: null, 236 error: c.json({ 237 error: "Forum agent not authenticated. Please try again later.", 238 }, 503) as unknown as Response, 239 }; 240 } 241 242 return { agent, error: null }; 243} 244 245/** 246 * Parse JSON body and return 400 if malformed. 247 * 248 * Returns `{ body, error }` where: 249 * - On success: `{ body: parsedObject, error: null }` 250 * - On failure: `{ body: null, error: Response }` — caller should `return error` 251 * 252 * Usage: 253 * ```typescript 254 * const { body, error } = await safeParseJsonBody(c); 255 * if (error) return error; 256 * const { text, boardUri } = body; 257 * ``` 258 */ 259export async function safeParseJsonBody( 260 c: Context 261): Promise<{ body: any; error: null } | { body: null; error: Response }> { 262 try { 263 const body = await c.req.json(); 264 return { body, error: null }; 265 } catch (error) { 266 // Only SyntaxError is expected here (malformed JSON from user input). 267 // Re-throw anything unexpected so programming bugs are not silently swallowed. 268 if (!(error instanceof SyntaxError)) throw error; 269 return { 270 body: null, 271 error: c.json({ error: "Invalid JSON in request body" }, 400) as unknown as Response, 272 }; 273 } 274}