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 { Hono } from "hono";
2import type { AppContext } from "../lib/app-context.js";
3import type { Variables } from "../types.js";
4import { posts, users } from "@atbb/db";
5import { eq, and, asc, count } from "drizzle-orm";
6import { TID } from "@atproto/common-web";
7import { requireAuth } from "../middleware/auth.js";
8import { requirePermission } from "../middleware/permissions.js";
9import { requireNotBanned } from "../middleware/require-not-banned.js";
10import { parseAtUri } from "../lib/at-uri.js";
11import { handleRouteError, safeParseJsonBody } from "../lib/route-errors.js";
12import { isProgrammingError } from "../lib/errors.js";
13import {
14 parseBigIntParam,
15 serializePost,
16 validatePostText,
17 validateTopicTitle,
18 getForumByUri,
19 getBoardByUri,
20 getActiveBans,
21 getHiddenPosts,
22 getTopicModStatus,
23} from "./helpers.js";
24
25/**
26 * Factory function that creates topic routes with access to app context.
27 */
28export function createTopicsRoutes(ctx: AppContext) {
29 return new Hono<{ Variables: Variables }>()
30 .get("/:id", async (c) => {
31 const { id } = c.req.param();
32
33 const topicId = parseBigIntParam(id);
34 if (topicId === null) {
35 return c.json({ error: "Invalid topic ID format" }, 400);
36 }
37
38 // Parse pagination params (same pattern as boards/:id/topics, higher cap for bookmark support)
39 const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10);
40 const limitRaw = parseInt(c.req.query("limit") ?? "25", 10);
41 const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw;
42 const limit = isNaN(limitRaw) || limitRaw < 1 ? 25 : Math.min(limitRaw, 250);
43
44 try {
45 // Query the thread starter post
46 const [topicResult] = await ctx.db
47 .select({
48 post: posts,
49 author: users,
50 })
51 .from(posts)
52 .leftJoin(users, eq(posts.did, users.did))
53 .where(and(eq(posts.id, topicId), eq(posts.bannedByMod, false)))
54 .limit(1);
55
56 if (!topicResult) {
57 return c.json({ error: "Topic not found" }, 404);
58 }
59
60 // Query reply count + paginated replies in parallel — separate inner try so a
61 // failure here logs "replies" context rather than "topic" (CLAUDE.md try-block granularity)
62 const replyFilter = and(eq(posts.rootPostId, topicId), eq(posts.bannedByMod, false));
63 let countResult: { count: number }[];
64 let replyResults: { post: typeof posts.$inferSelect; author: typeof users.$inferSelect | null }[];
65 try {
66 [countResult, replyResults] = await Promise.all([
67 ctx.db
68 .select({ count: count() })
69 .from(posts)
70 .where(replyFilter),
71 ctx.db
72 .select({
73 post: posts,
74 author: users,
75 })
76 .from(posts)
77 .leftJoin(users, eq(posts.did, users.did))
78 .where(replyFilter)
79 .orderBy(asc(posts.createdAt))
80 .limit(limit)
81 .offset(offset),
82 ]);
83 } catch (error) {
84 return handleRouteError(c, error, "Failed to retrieve replies for topic", {
85 operation: "GET /api/topics/:id - reply query",
86 logger: ctx.logger,
87 topicId: id,
88 });
89 }
90
91 // total counts replies passing the SQL bannedByMod filter only.
92 // In-memory filters (getActiveBans, getHiddenPosts) may reduce the
93 // visible reply count further — clients should treat total as approximate.
94 const total = Number(countResult[0]?.count ?? 0);
95
96 // Get banned users - fail open (show content if ban lookup fails)
97 const allUserDids = [
98 topicResult.post.did,
99 ...replyResults.map((r) => r.post.did),
100 ];
101 let bannedUsers = new Set<string>();
102 try {
103 bannedUsers = await getActiveBans(ctx.db, allUserDids, ctx.logger);
104 } catch (error) {
105 if (isProgrammingError(error)) throw error;
106 ctx.logger.error("Failed to query bans for topic view - showing all replies", {
107 operation: "GET /api/topics/:id - ban check",
108 topicId: id,
109 error: error instanceof Error ? error.message : String(error),
110 });
111 }
112
113 // Get hidden posts - fail open (show content if hide lookup fails)
114 const allPostIds = replyResults.map((r) => r.post.id);
115 let hiddenPosts = new Set<bigint>();
116 try {
117 hiddenPosts = await getHiddenPosts(ctx.db, allPostIds, ctx.logger);
118 } catch (error) {
119 if (isProgrammingError(error)) throw error;
120 ctx.logger.error("Failed to query hidden posts for topic view - showing all replies", {
121 operation: "GET /api/topics/:id - hidden posts",
122 topicId: id,
123 error: error instanceof Error ? error.message : String(error),
124 });
125 }
126
127 // Filter replies - exclude banned users and hidden posts
128 const filteredReplies = replyResults.filter(
129 ({ post }) => !bannedUsers.has(post.did) && !hiddenPosts.has(post.id)
130 );
131
132 // Get lock/pin status - fail open (default unlocked/unpinned if lookup fails)
133 let modStatus = { locked: false, pinned: false };
134 try {
135 modStatus = await getTopicModStatus(ctx.db, topicId, ctx.logger);
136 } catch (error) {
137 if (isProgrammingError(error)) throw error;
138 ctx.logger.error("Failed to query topic mod status - showing as unlocked", {
139 operation: "GET /api/topics/:id - mod status",
140 topicId: id,
141 error: error instanceof Error ? error.message : String(error),
142 });
143 }
144
145 const { post: topicPost, author: topicAuthor } = topicResult;
146
147 return c.json({
148 topicId: id,
149 locked: modStatus.locked,
150 pinned: modStatus.pinned,
151 post: serializePost(topicPost, topicAuthor),
152 replies: filteredReplies.map(({ post, author }) =>
153 serializePost(post, author)
154 ),
155 total,
156 offset,
157 limit,
158 });
159 } catch (error) {
160 return handleRouteError(c, error, "Failed to retrieve topic", {
161 operation: "GET /api/topics/:id - topic query",
162 logger: ctx.logger,
163 topicId: id,
164 });
165 }
166 })
167 // Ban check runs before permission check so banned users receive "You are banned"
168 // rather than a generic "Permission denied" response.
169 .post("/", requireAuth(ctx), requireNotBanned(ctx), requirePermission(ctx, "space.atbb.permission.createTopics"), async (c) => {
170 // user is guaranteed to exist after requireAuth, requireNotBanned, and requirePermission middleware
171 const user = c.get("user")!;
172
173 // Parse and validate request body
174 const { body, error: parseError } = await safeParseJsonBody(c);
175 if (parseError) return parseError;
176
177 const { title, text, boardUri } = body;
178
179 // Validate title
180 // Note: the lexicon marks `title` as optional (required only for topics, not replies).
181 // AT Protocol record schemas cannot express per-use-case requirements.
182 // AppView enforces it as mandatory here for all topic creation requests.
183 const titleValidation = validateTopicTitle(title);
184 if (!titleValidation.valid) {
185 return c.json({ error: titleValidation.error }, 400);
186 }
187
188 // Validate text
189 const validation = validatePostText(text);
190 if (!validation.valid) {
191 return c.json({ error: validation.error }, 400);
192 }
193
194 // Validate boardUri is required
195 if (typeof boardUri !== "string" || !boardUri.trim()) {
196 return c.json({ error: "boardUri is required" }, 400);
197 }
198
199 // Validate boardUri format
200 const parsedBoardUri = parseAtUri(boardUri);
201 if (!parsedBoardUri) {
202 return c.json({ error: "Invalid boardUri format" }, 400);
203 }
204
205 // Validate collection type
206 if (!parsedBoardUri.collection.startsWith("space.atbb.forum.board")) {
207 return c.json({ error: "boardUri must reference a board" }, 400);
208 }
209
210 // Validate ownership
211 if (parsedBoardUri.did !== ctx.config.forumDid) {
212 return c.json({ error: "boardUri must belong to this forum" }, 400);
213 }
214
215 // Always use the configured singleton forum
216 const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`;
217
218 // Phase 1a: Look up forum — separated from the PDS write so a database
219 // error cannot be misclassified as a PDS failure.
220 let forum: { did: string; rkey: string; cid: string } | null = null;
221
222 try {
223 forum = await getForumByUri(ctx.db, forumUri);
224 if (!forum) {
225 return c.json({ error: "Forum not found" }, 404);
226 }
227 } catch (error) {
228 if (isProgrammingError(error)) {
229 ctx.logger.error("CRITICAL: Programming error in POST /api/topics (forum lookup)", {
230 operation: "POST /api/topics",
231 userId: user.did,
232 error: error instanceof Error ? error.message : String(error),
233 stack: error instanceof Error ? error.stack : undefined,
234 });
235 throw error;
236 }
237
238 ctx.logger.error("Failed to look up forum record before writing topic to PDS", {
239 operation: "POST /api/topics",
240 userId: user.did,
241 error: error instanceof Error ? error.message : String(error),
242 });
243
244 return c.json(
245 { error: "Database temporarily unavailable. Please try again later." },
246 503
247 );
248 }
249
250 // Phase 1b: Look up board
251 let board: { cid: string } | null = null;
252
253 try {
254 board = await getBoardByUri(ctx.db, boardUri);
255 if (!board) {
256 return c.json({ error: "Board not found" }, 404);
257 }
258 } catch (error) {
259 if (isProgrammingError(error)) {
260 ctx.logger.error("CRITICAL: Programming error in POST /api/topics (board lookup)", {
261 operation: "POST /api/topics",
262 userId: user.did,
263 error: error instanceof Error ? error.message : String(error),
264 stack: error instanceof Error ? error.stack : undefined,
265 });
266 throw error;
267 }
268
269 ctx.logger.error("Failed to look up board record before writing topic to PDS", {
270 operation: "POST /api/topics",
271 userId: user.did,
272 error: error instanceof Error ? error.message : String(error),
273 });
274
275 return c.json(
276 { error: "Database temporarily unavailable. Please try again later." },
277 503
278 );
279 }
280
281 // Generate TID for rkey
282 const rkey = TID.nextStr();
283
284 // Phase 2: PDS write — forum and board are non-null (null case returns 404 above)
285 try {
286 const result = await user.agent.com.atproto.repo.putRecord({
287 repo: user.did,
288 collection: "space.atbb.post",
289 rkey,
290 record: {
291 $type: "space.atbb.post",
292 title: titleValidation.trimmed!,
293 text: validation.trimmed!,
294 forum: {
295 $type: "space.atbb.post#forumRef",
296 forum: { uri: forumUri, cid: forum!.cid },
297 },
298 board: {
299 $type: "space.atbb.post#boardRef",
300 board: { uri: boardUri, cid: board!.cid },
301 },
302 createdAt: new Date().toISOString(),
303 },
304 });
305
306 return c.json(
307 {
308 uri: result.data.uri,
309 cid: result.data.cid,
310 rkey,
311 },
312 201
313 );
314 } catch (error) {
315 return handleRouteError(c, error, "Failed to create topic", {
316 operation: "POST /api/topics",
317 logger: ctx.logger,
318 userId: user.did,
319 });
320 }
321 });
322}