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
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}