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 { eq, desc } from "drizzle-orm";
3import { modActions, memberships, posts } from "@atbb/db";
4import { TID } from "@atproto/common-web";
5import { requireAuth } from "../middleware/auth.js";
6import { requirePermission } from "../middleware/permissions.js";
7import { isProgrammingError } from "../lib/errors.js";
8import {
9 handleRouteError,
10 safeParseJsonBody,
11 getForumAgentOrError,
12} from "../lib/route-errors.js";
13import { parseBigIntParam } from "./helpers.js";
14import type { AppContext } from "../lib/app-context.js";
15import type { Variables } from "../types.js";
16
17/**
18 * Subject of a moderation action - either a user (DID) or a post (URI).
19 */
20export type ModSubject = { did: string } | { postUri: string };
21
22/**
23 * Validate reason field (required, 1-3000 chars).
24 * @returns null if valid, error message string if invalid
25 */
26export function validateReason(reason: unknown): string | null {
27 if (typeof reason !== "string") {
28 return "Reason is required and must be a string";
29 }
30
31 const trimmed = reason.trim();
32 if (trimmed.length === 0) {
33 return "Reason is required and must not be empty";
34 }
35
36 if (trimmed.length > 3000) {
37 return "Reason must not exceed 3000 characters";
38 }
39
40 return null;
41}
42
43/**
44 * Check if a specific moderation action is currently active for a subject.
45 * Queries the most recent modAction record for the subject.
46 *
47 * @returns true if action is active, false if reversed/inactive, null if no actions exist
48 */
49export async function checkActiveAction(
50 ctx: AppContext,
51 subject: ModSubject,
52 actionType: string
53): Promise<boolean | null> {
54 try {
55 // Build WHERE clause based on subject type
56 const whereClause =
57 "did" in subject
58 ? eq(modActions.subjectDid, subject.did)
59 : eq(modActions.subjectPostUri, subject.postUri);
60
61 // Query most recent action for this subject
62 const [mostRecent] = await ctx.db
63 .select()
64 .from(modActions)
65 .where(whereClause)
66 .orderBy(desc(modActions.createdAt))
67 .limit(1);
68
69 // No actions exist for this subject
70 if (!mostRecent) {
71 return null;
72 }
73
74 // Action is active if most recent action matches the requested type
75 return mostRecent.action === actionType;
76 } catch (error) {
77 // Re-throw programming errors (code bugs) - don't hide them
78 if (isProgrammingError(error)) {
79 ctx.logger.error("CRITICAL: Programming error in checkActiveAction", {
80 operation: "checkActiveAction",
81 subject: JSON.stringify(subject),
82 actionType,
83 error: error instanceof Error ? error.message : String(error),
84 stack: error instanceof Error ? error.stack : undefined,
85 });
86 throw error;
87 }
88
89 // Fail safe: return null on runtime errors (don't expose DB errors to callers)
90 ctx.logger.error("Failed to check active moderation action", {
91 operation: "checkActiveAction",
92 subject: JSON.stringify(subject),
93 actionType,
94 error: error instanceof Error ? error.message : String(error),
95 });
96 return null;
97 }
98}
99
100export function createModRoutes(ctx: AppContext) {
101 const app = new Hono<{ Variables: Variables }>();
102
103 // POST /api/mod/ban - Ban a user
104 app.post(
105 "/ban",
106 requireAuth(ctx),
107 requirePermission(ctx, "space.atbb.permission.banUsers"),
108 async (c) => {
109 // Parse request body
110 const { body, error: parseError } = await safeParseJsonBody(c);
111 if (parseError) return parseError;
112
113 const { targetDid, reason } = body;
114
115 // Validate targetDid
116 if (typeof targetDid !== "string" || !targetDid.startsWith("did:")) {
117 return c.json({ error: "Invalid DID format" }, 400);
118 }
119
120 // Validate reason
121 const reasonError = validateReason(reason);
122 if (reasonError) {
123 return c.json({ error: reasonError }, 400);
124 }
125
126 // Check if target user has membership (404 if not found)
127 try {
128 const [membership] = await ctx.db
129 .select()
130 .from(memberships)
131 .where(eq(memberships.did, targetDid))
132 .limit(1);
133
134 if (!membership) {
135 return c.json({ error: "Target user not found" }, 404);
136 }
137 } catch (error) {
138 return handleRouteError(c, error, "Failed to check user membership", {
139 operation: "POST /api/mod/ban",
140 logger: ctx.logger,
141 targetDid,
142 });
143 }
144
145 // Check if user is already banned
146 const isAlreadyBanned = await checkActiveAction(
147 ctx,
148 { did: targetDid },
149 "space.atbb.modAction.ban"
150 );
151
152 if (isAlreadyBanned === true) {
153 return c.json({
154 success: true,
155 action: "space.atbb.modAction.ban",
156 targetDid,
157 uri: null,
158 cid: null,
159 alreadyActive: true,
160 });
161 }
162
163 // Get ForumAgent
164 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/mod/ban");
165 if (agentError) return agentError;
166
167 // Write modAction record to Forum DID's PDS
168 const user = c.get("user")!;
169 const rkey = TID.nextStr();
170
171 try {
172 const result = await agent.com.atproto.repo.putRecord({
173 repo: ctx.config.forumDid,
174 collection: "space.atbb.modAction",
175 rkey,
176 record: {
177 $type: "space.atbb.modAction",
178 action: "space.atbb.modAction.ban",
179 subject: { did: targetDid },
180 reason: reason.trim(),
181 createdBy: user.did,
182 createdAt: new Date().toISOString(),
183 },
184 });
185
186 return c.json({
187 success: true,
188 action: "space.atbb.modAction.ban",
189 targetDid,
190 uri: result.data.uri,
191 cid: result.data.cid,
192 alreadyActive: false,
193 });
194 } catch (error) {
195 return handleRouteError(c, error, "Failed to record moderation action", {
196 operation: "POST /api/mod/ban",
197 logger: ctx.logger,
198 moderatorDid: user.did,
199 targetDid,
200 forumDid: ctx.config.forumDid,
201 action: "space.atbb.modAction.ban",
202 });
203 }
204 }
205 );
206
207 // DELETE /api/mod/ban/:did - Unban a user
208 app.delete(
209 "/ban/:did",
210 requireAuth(ctx),
211 requirePermission(ctx, "space.atbb.permission.banUsers"),
212 async (c) => {
213 // Get DID from route param
214 const targetDid = c.req.param("did");
215
216 // Validate targetDid format
217 if (!targetDid || !targetDid.startsWith("did:")) {
218 return c.json({ error: "Invalid DID format" }, 400);
219 }
220
221 // Parse request body
222 const { body, error: parseError } = await safeParseJsonBody(c);
223 if (parseError) return parseError;
224
225 const { reason } = body;
226
227 // Validate reason
228 const reasonError = validateReason(reason);
229 if (reasonError) {
230 return c.json({ error: reasonError }, 400);
231 }
232
233 // Check if target user has membership (404 if not found)
234 try {
235 const [membership] = await ctx.db
236 .select()
237 .from(memberships)
238 .where(eq(memberships.did, targetDid))
239 .limit(1);
240
241 if (!membership) {
242 return c.json({ error: "Target user not found" }, 404);
243 }
244 } catch (error) {
245 return handleRouteError(c, error, "Failed to check user membership", {
246 operation: "DELETE /api/mod/ban/:did",
247 logger: ctx.logger,
248 targetDid,
249 });
250 }
251
252 // Check if user is already unbanned (not banned)
253 const isBanned = await checkActiveAction(
254 ctx,
255 { did: targetDid },
256 "space.atbb.modAction.ban"
257 );
258
259 // If user is not banned (false) or no actions exist (null), they're already unbanned
260 if (isBanned === false || isBanned === null) {
261 return c.json({
262 success: true,
263 action: "space.atbb.modAction.unban",
264 targetDid,
265 uri: null,
266 cid: null,
267 alreadyActive: true,
268 });
269 }
270
271 // Get ForumAgent
272 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/mod/ban/:did");
273 if (agentError) return agentError;
274
275 // Write unban modAction record to Forum DID's PDS
276 const user = c.get("user")!;
277 const rkey = TID.nextStr();
278
279 try {
280 const result = await agent.com.atproto.repo.putRecord({
281 repo: ctx.config.forumDid,
282 collection: "space.atbb.modAction",
283 rkey,
284 record: {
285 $type: "space.atbb.modAction",
286 action: "space.atbb.modAction.unban",
287 subject: { did: targetDid },
288 reason: reason.trim(),
289 createdBy: user.did,
290 createdAt: new Date().toISOString(),
291 },
292 });
293
294 return c.json({
295 success: true,
296 action: "space.atbb.modAction.unban",
297 targetDid,
298 uri: result.data.uri,
299 cid: result.data.cid,
300 alreadyActive: false,
301 });
302 } catch (error) {
303 return handleRouteError(c, error, "Failed to record moderation action", {
304 operation: "DELETE /api/mod/ban/:did",
305 logger: ctx.logger,
306 moderatorDid: user.did,
307 targetDid,
308 forumDid: ctx.config.forumDid,
309 action: "space.atbb.modAction.unban",
310 });
311 }
312 }
313 );
314
315 // POST /api/mod/lock - Lock a topic (prevent new replies)
316 app.post(
317 "/lock",
318 requireAuth(ctx),
319 requirePermission(ctx, "space.atbb.permission.lockTopics"),
320 async (c) => {
321 // Parse request body
322 const { body, error: parseError } = await safeParseJsonBody(c);
323 if (parseError) return parseError;
324
325 const { topicId, reason } = body;
326
327 // Validate topicId
328 if (typeof topicId !== "string") {
329 return c.json({ error: "Topic ID is required and must be a string" }, 400);
330 }
331
332 const topicIdBigInt = parseBigIntParam(topicId);
333 if (topicIdBigInt === null) {
334 return c.json({ error: "Invalid topic ID format" }, 400);
335 }
336
337 // Validate reason
338 const reasonError = validateReason(reason);
339 if (reasonError) {
340 return c.json({ error: reasonError }, 400);
341 }
342
343 // Get topic from posts table
344 let topic;
345 try {
346 const [result] = await ctx.db
347 .select()
348 .from(posts)
349 .where(eq(posts.id, topicIdBigInt))
350 .limit(1);
351
352 if (!result) {
353 return c.json({ error: "Topic not found" }, 404);
354 }
355
356 topic = result;
357 } catch (error) {
358 return handleRouteError(c, error, "Failed to check topic", {
359 operation: "POST /api/mod/lock",
360 logger: ctx.logger,
361 topicId,
362 });
363 }
364
365 // Validate it's a root post (topic, not reply)
366 if (topic.rootPostId !== null) {
367 return c.json({ error: "Can only lock topic posts, not replies" }, 400);
368 }
369
370 // Build post URI
371 const postUri = `at://${topic.did}/space.atbb.post/${topic.rkey}`;
372
373 // Check if already locked
374 const isAlreadyLocked = await checkActiveAction(
375 ctx,
376 { postUri },
377 "space.atbb.modAction.lock"
378 );
379
380 if (isAlreadyLocked === true) {
381 return c.json({
382 success: true,
383 action: "space.atbb.modAction.lock",
384 topicId: topicId,
385 uri: null,
386 cid: null,
387 alreadyActive: true,
388 });
389 }
390
391 // Get ForumAgent
392 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/mod/lock");
393 if (agentError) return agentError;
394
395 // Write modAction record to Forum DID's PDS
396 const user = c.get("user")!;
397 const rkey = TID.nextStr();
398
399 try {
400 const result = await agent.com.atproto.repo.putRecord({
401 repo: ctx.config.forumDid,
402 collection: "space.atbb.modAction",
403 rkey,
404 record: {
405 $type: "space.atbb.modAction",
406 action: "space.atbb.modAction.lock",
407 subject: {
408 post: {
409 uri: postUri,
410 cid: topic.cid,
411 },
412 },
413 reason: reason.trim(),
414 createdBy: user.did,
415 createdAt: new Date().toISOString(),
416 },
417 });
418
419 return c.json({
420 success: true,
421 action: "space.atbb.modAction.lock",
422 topicId: topicId,
423 uri: result.data.uri,
424 cid: result.data.cid,
425 alreadyActive: false,
426 });
427 } catch (error) {
428 return handleRouteError(c, error, "Failed to record moderation action", {
429 operation: "POST /api/mod/lock",
430 logger: ctx.logger,
431 moderatorDid: user.did,
432 topicId,
433 postUri,
434 forumDid: ctx.config.forumDid,
435 action: "space.atbb.modAction.lock",
436 });
437 }
438 }
439 );
440
441 // DELETE /api/mod/lock/:topicId - Unlock a topic (allow new replies)
442 app.delete(
443 "/lock/:topicId",
444 requireAuth(ctx),
445 requirePermission(ctx, "space.atbb.permission.lockTopics"),
446 async (c) => {
447 // Get topicId from route param
448 const topicIdParam = c.req.param("topicId");
449
450 // Validate topicId format
451 const topicIdBigInt = parseBigIntParam(topicIdParam);
452 if (topicIdBigInt === null) {
453 return c.json({ error: "Invalid topic ID format" }, 400);
454 }
455
456 // Parse request body
457 const { body, error: parseError } = await safeParseJsonBody(c);
458 if (parseError) return parseError;
459
460 const { reason } = body;
461
462 // Validate reason
463 const reasonError = validateReason(reason);
464 if (reasonError) {
465 return c.json({ error: reasonError }, 400);
466 }
467
468 // Get topic from posts table
469 let topic;
470 try {
471 const [result] = await ctx.db
472 .select()
473 .from(posts)
474 .where(eq(posts.id, topicIdBigInt))
475 .limit(1);
476
477 if (!result) {
478 return c.json({ error: "Topic not found" }, 404);
479 }
480
481 topic = result;
482 } catch (error) {
483 return handleRouteError(c, error, "Failed to check topic", {
484 operation: "DELETE /api/mod/lock/:topicId",
485 logger: ctx.logger,
486 topicId: topicIdParam,
487 });
488 }
489
490 // Validate it's a root post (topic, not reply)
491 if (topic.rootPostId !== null) {
492 return c.json({ error: "Can only unlock topic posts, not replies" }, 400);
493 }
494
495 // Build post URI
496 const postUri = `at://${topic.did}/space.atbb.post/${topic.rkey}`;
497
498 // Check if topic is already unlocked (not locked)
499 const isLocked = await checkActiveAction(
500 ctx,
501 { postUri },
502 "space.atbb.modAction.lock"
503 );
504
505 // If topic is not locked (false) or no actions exist (null), it's already unlocked
506 if (isLocked === false || isLocked === null) {
507 return c.json({
508 success: true,
509 action: "space.atbb.modAction.unlock",
510 topicId: topicIdParam,
511 uri: null,
512 cid: null,
513 alreadyActive: true,
514 });
515 }
516
517 // Get ForumAgent
518 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/mod/lock/:topicId");
519 if (agentError) return agentError;
520
521 // Write unlock modAction record to Forum DID's PDS
522 const user = c.get("user")!;
523 const rkey = TID.nextStr();
524
525 try {
526 const result = await agent.com.atproto.repo.putRecord({
527 repo: ctx.config.forumDid,
528 collection: "space.atbb.modAction",
529 rkey,
530 record: {
531 $type: "space.atbb.modAction",
532 action: "space.atbb.modAction.unlock",
533 subject: {
534 post: {
535 uri: postUri,
536 cid: topic.cid,
537 },
538 },
539 reason: reason.trim(),
540 createdBy: user.did,
541 createdAt: new Date().toISOString(),
542 },
543 });
544
545 return c.json({
546 success: true,
547 action: "space.atbb.modAction.unlock",
548 topicId: topicIdParam,
549 uri: result.data.uri,
550 cid: result.data.cid,
551 alreadyActive: false,
552 });
553 } catch (error) {
554 return handleRouteError(c, error, "Failed to record moderation action", {
555 operation: "DELETE /api/mod/lock/:topicId",
556 logger: ctx.logger,
557 moderatorDid: user.did,
558 topicId: topicIdParam,
559 postUri,
560 forumDid: ctx.config.forumDid,
561 action: "space.atbb.modAction.unlock",
562 });
563 }
564 }
565 );
566
567 /**
568 * POST /api/mod/hide
569 * Hide a post from the forum (soft-delete).
570 * Note: Uses "space.atbb.modAction.delete" action type. The read-path logic
571 * determines the current state by looking at the most recent action chronologically.
572 * Unhide uses a separate "space.atbb.modAction.undelete" action type.
573 */
574 app.post(
575 "/hide",
576 requireAuth(ctx),
577 requirePermission(ctx, "space.atbb.permission.moderatePosts"),
578 async (c) => {
579 const user = c.get("user")!;
580
581 // Parse request body
582 const { body, error: parseError } = await safeParseJsonBody(c);
583 if (parseError) return parseError;
584
585 const { postId, reason } = body;
586
587 // Validate postId
588 if (typeof postId !== "string") {
589 return c.json({ error: "postId is required and must be a string" }, 400);
590 }
591
592 const postIdBigInt = parseBigIntParam(postId);
593 if (postIdBigInt === null) {
594 return c.json({ error: "Invalid post ID" }, 400);
595 }
596
597 // Validate reason
598 const reasonError = validateReason(reason);
599 if (reasonError) {
600 return c.json({ error: reasonError }, 400);
601 }
602
603 // Get post (can be topic or reply)
604 let post;
605 try {
606 const [result] = await ctx.db
607 .select()
608 .from(posts)
609 .where(eq(posts.id, postIdBigInt))
610 .limit(1);
611
612 if (!result) {
613 return c.json({ error: "Post not found" }, 404);
614 }
615
616 post = result;
617 } catch (error) {
618 return handleRouteError(c, error, "Failed to retrieve post", {
619 operation: "POST /api/mod/hide",
620 logger: ctx.logger,
621 postId,
622 });
623 }
624
625 const postUri = `at://${post.did}/space.atbb.post/${post.rkey}`;
626
627 // Check if post is already hidden
628 const isHidden = await checkActiveAction(
629 ctx,
630 { postUri },
631 "space.atbb.modAction.delete"
632 );
633
634 if (isHidden) {
635 return c.json({
636 success: true,
637 action: "space.atbb.modAction.delete",
638 postId: postId,
639 postUri: postUri,
640 uri: null,
641 cid: null,
642 alreadyActive: true,
643 }, 200);
644 }
645
646 // Get ForumAgent
647 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/mod/hide");
648 if (agentError) return agentError;
649
650 // Write hide modAction record (action type is "delete" per lexicon)
651 try {
652 const rkey = TID.nextStr();
653 const result = await agent.com.atproto.repo.putRecord({
654 repo: ctx.config.forumDid,
655 collection: "space.atbb.modAction",
656 rkey,
657 record: {
658 $type: "space.atbb.modAction",
659 action: "space.atbb.modAction.delete",
660 subject: {
661 post: {
662 uri: postUri,
663 cid: post.cid,
664 },
665 },
666 reason: reason.trim(),
667 createdBy: user.did,
668 createdAt: new Date().toISOString(),
669 },
670 });
671
672 return c.json({
673 success: true,
674 action: "space.atbb.modAction.delete",
675 postId: postId,
676 postUri: postUri,
677 uri: result.data.uri,
678 cid: result.data.cid,
679 alreadyActive: false,
680 }, 200);
681 } catch (error) {
682 return handleRouteError(c, error, "Failed to record moderation action", {
683 operation: "POST /api/mod/hide",
684 logger: ctx.logger,
685 moderatorDid: user.did,
686 postId,
687 postUri,
688 forumDid: ctx.config.forumDid,
689 action: "space.atbb.modAction.delete",
690 });
691 }
692 }
693 );
694
695 /**
696 * DELETE /api/mod/hide/:postId
697 * Unhide a post (reversal action).
698 * Note: Uses "space.atbb.modAction.undelete" action type to reverse hide.
699 * The read-path logic determines the current state by looking at the most
700 * recent action chronologically (alternating hide/unhide creates a toggle effect).
701 */
702 app.delete(
703 "/hide/:postId",
704 requireAuth(ctx),
705 requirePermission(ctx, "space.atbb.permission.moderatePosts"),
706 async (c) => {
707 const user = c.get("user")!;
708 const postIdParam = c.req.param("postId");
709
710 const postIdBigInt = parseBigIntParam(postIdParam);
711 if (postIdBigInt === null) {
712 return c.json({ error: "Invalid post ID" }, 400);
713 }
714
715 // Parse request body
716 const { body, error: parseError } = await safeParseJsonBody(c);
717 if (parseError) return parseError;
718
719 const { reason } = body;
720
721 // Validate reason
722 const reasonError = validateReason(reason);
723 if (reasonError) {
724 return c.json({ error: reasonError }, 400);
725 }
726
727 // Get post
728 let post;
729 try {
730 const [result] = await ctx.db
731 .select()
732 .from(posts)
733 .where(eq(posts.id, postIdBigInt))
734 .limit(1);
735
736 if (!result) {
737 return c.json({ error: "Post not found" }, 404);
738 }
739
740 post = result;
741 } catch (error) {
742 return handleRouteError(c, error, "Failed to retrieve post", {
743 operation: "DELETE /api/mod/hide/:postId",
744 logger: ctx.logger,
745 postId: postIdParam,
746 });
747 }
748
749 const postUri = `at://${post.did}/space.atbb.post/${post.rkey}`;
750
751 // Check if post is currently hidden
752 const isHidden = await checkActiveAction(
753 ctx,
754 { postUri },
755 "space.atbb.modAction.delete"
756 );
757
758 if (isHidden === false || isHidden === null) {
759 return c.json({
760 success: true,
761 action: "space.atbb.modAction.undelete",
762 postId: postIdParam,
763 postUri: postUri,
764 uri: null,
765 cid: null,
766 alreadyActive: true,
767 }, 200);
768 }
769
770 // Get ForumAgent
771 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/mod/hide/:postId");
772 if (agentError) return agentError;
773
774 // Write unhide modAction record
775 // Uses "undelete" action type for reversal (hide→unhide toggle)
776 try {
777 const rkey = TID.nextStr();
778 const result = await agent.com.atproto.repo.putRecord({
779 repo: ctx.config.forumDid,
780 collection: "space.atbb.modAction",
781 rkey,
782 record: {
783 $type: "space.atbb.modAction",
784 action: "space.atbb.modAction.undelete",
785 subject: {
786 post: {
787 uri: postUri,
788 cid: post.cid,
789 },
790 },
791 reason: reason.trim(),
792 createdBy: user.did,
793 createdAt: new Date().toISOString(),
794 },
795 });
796
797 return c.json({
798 success: true,
799 action: "space.atbb.modAction.undelete",
800 postId: postIdParam,
801 postUri: postUri,
802 uri: result.data.uri,
803 cid: result.data.cid,
804 alreadyActive: false,
805 }, 200);
806 } catch (error) {
807 return handleRouteError(c, error, "Failed to record moderation action", {
808 operation: "DELETE /api/mod/hide/:postId",
809 logger: ctx.logger,
810 moderatorDid: user.did,
811 postId: postIdParam,
812 postUri,
813 forumDid: ctx.config.forumDid,
814 action: "space.atbb.modAction.undelete",
815 });
816 }
817 }
818 );
819
820 return app;
821}