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 route handlers and security checks.
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 handleRouteError(
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 * Get the ForumAgent's authenticated AtpAgent, or return an error response.
85 *
86 * Checks both that ForumAgent exists (server configuration) and that it's
87 * authenticated (has valid credentials). Returns appropriate error responses:
88 * - 500 if ForumAgent is not configured (server misconfiguration)
89 * - 503 if ForumAgent is not authenticated (temporary, should retry)
90 *
91 * Usage:
92 * ```typescript
93 * const { agent, error } = getForumAgentOrError(ctx, c, "POST /api/mod/ban");
94 * if (error) return error;
95 * // agent is guaranteed to be non-null here
96 * ```
97 */
98export function getForumAgentOrError(
99 appCtx: AppContext,
100 c: Context,
101 operation: string
102): { agent: AtpAgent; error: null } | { agent: null; error: Response } {
103 if (!appCtx.forumAgent) {
104 appCtx.logger.error("CRITICAL: ForumAgent not available", {
105 operation,
106 forumDid: appCtx.config.forumDid,
107 });
108 return {
109 agent: null,
110 error: c.json({
111 error: "Forum agent not available. Server configuration issue.",
112 }, 500) as unknown as Response,
113 };
114 }
115
116 const agent = appCtx.forumAgent.getAgent();
117 if (!agent) {
118 appCtx.logger.error("ForumAgent not authenticated", {
119 operation,
120 forumDid: appCtx.config.forumDid,
121 });
122 return {
123 agent: null,
124 error: c.json({
125 error: "Forum agent not authenticated. Please try again later.",
126 }, 503) as unknown as Response,
127 };
128 }
129
130 return { agent, error: null };
131}
132
133/**
134 * Parse JSON body and return 400 if malformed.
135 *
136 * Returns `{ body, error }` where:
137 * - On success: `{ body: parsedObject, error: null }`
138 * - On failure: `{ body: null, error: Response }` — caller should `return error`
139 *
140 * Usage:
141 * ```typescript
142 * const { body, error } = await safeParseJsonBody(c);
143 * if (error) return error;
144 * const { text, boardUri } = body;
145 * ```
146 */
147export async function safeParseJsonBody(
148 c: Context
149): Promise<{ body: any; error: null } | { body: null; error: Response }> {
150 try {
151 const body = await c.req.json();
152 return { body, error: null };
153 } catch (error) {
154 // Only SyntaxError is expected here (malformed JSON from user input).
155 // Re-throw anything unexpected so programming bugs are not silently swallowed.
156 if (!(error instanceof SyntaxError)) throw error;
157 return {
158 body: null,
159 error: c.json({ error: "Invalid JSON in request body" }, 400) as unknown as Response,
160 };
161 }
162}