import { Hono } from "hono"; import { eq, desc } from "drizzle-orm"; import { modActions, memberships, posts } from "@atbb/db"; import { TID } from "@atproto/common-web"; import { requireAuth } from "../middleware/auth.js"; import { requirePermission } from "../middleware/permissions.js"; import { isProgrammingError } from "../lib/errors.js"; import { handleRouteError, safeParseJsonBody, getForumAgentOrError, } from "../lib/route-errors.js"; import { parseBigIntParam } from "./helpers.js"; import type { AppContext } from "../lib/app-context.js"; import type { Variables } from "../types.js"; /** * Subject of a moderation action - either a user (DID) or a post (URI). */ export type ModSubject = { did: string } | { postUri: string }; /** * Validate reason field (required, 1-3000 chars). * @returns null if valid, error message string if invalid */ export function validateReason(reason: unknown): string | null { if (typeof reason !== "string") { return "Reason is required and must be a string"; } const trimmed = reason.trim(); if (trimmed.length === 0) { return "Reason is required and must not be empty"; } if (trimmed.length > 3000) { return "Reason must not exceed 3000 characters"; } return null; } /** * Check if a specific moderation action is currently active for a subject. * Queries the most recent modAction record for the subject. * * @returns true if action is active, false if reversed/inactive, null if no actions exist */ export async function checkActiveAction( ctx: AppContext, subject: ModSubject, actionType: string ): Promise { try { // Build WHERE clause based on subject type const whereClause = "did" in subject ? eq(modActions.subjectDid, subject.did) : eq(modActions.subjectPostUri, subject.postUri); // Query most recent action for this subject const [mostRecent] = await ctx.db .select() .from(modActions) .where(whereClause) .orderBy(desc(modActions.createdAt)) .limit(1); // No actions exist for this subject if (!mostRecent) { return null; } // Action is active if most recent action matches the requested type return mostRecent.action === actionType; } catch (error) { // Re-throw programming errors (code bugs) - don't hide them if (isProgrammingError(error)) { ctx.logger.error("CRITICAL: Programming error in checkActiveAction", { operation: "checkActiveAction", subject: JSON.stringify(subject), actionType, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); throw error; } // Fail safe: return null on runtime errors (don't expose DB errors to callers) ctx.logger.error("Failed to check active moderation action", { operation: "checkActiveAction", subject: JSON.stringify(subject), actionType, error: error instanceof Error ? error.message : String(error), }); return null; } } export function createModRoutes(ctx: AppContext) { const app = new Hono<{ Variables: Variables }>(); // POST /api/mod/ban - Ban a user app.post( "/ban", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.banUsers"), async (c) => { // Parse request body const { body, error: parseError } = await safeParseJsonBody(c); if (parseError) return parseError; const { targetDid, reason } = body; // Validate targetDid if (typeof targetDid !== "string" || !targetDid.startsWith("did:")) { return c.json({ error: "Invalid DID format" }, 400); } // Validate reason const reasonError = validateReason(reason); if (reasonError) { return c.json({ error: reasonError }, 400); } // Check if target user has membership (404 if not found) try { const [membership] = await ctx.db .select() .from(memberships) .where(eq(memberships.did, targetDid)) .limit(1); if (!membership) { return c.json({ error: "Target user not found" }, 404); } } catch (error) { return handleRouteError(c, error, "Failed to check user membership", { operation: "POST /api/mod/ban", logger: ctx.logger, targetDid, }); } // Check if user is already banned const isAlreadyBanned = await checkActiveAction( ctx, { did: targetDid }, "space.atbb.modAction.ban" ); if (isAlreadyBanned === true) { return c.json({ success: true, action: "space.atbb.modAction.ban", targetDid, uri: null, cid: null, alreadyActive: true, }); } // Get ForumAgent const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/mod/ban"); if (agentError) return agentError; // Write modAction record to Forum DID's PDS const user = c.get("user")!; const rkey = TID.nextStr(); try { const result = await agent.com.atproto.repo.putRecord({ repo: ctx.config.forumDid, collection: "space.atbb.modAction", rkey, record: { $type: "space.atbb.modAction", action: "space.atbb.modAction.ban", subject: { did: targetDid }, reason: reason.trim(), createdBy: user.did, createdAt: new Date().toISOString(), }, }); return c.json({ success: true, action: "space.atbb.modAction.ban", targetDid, uri: result.data.uri, cid: result.data.cid, alreadyActive: false, }); } catch (error) { return handleRouteError(c, error, "Failed to record moderation action", { operation: "POST /api/mod/ban", logger: ctx.logger, moderatorDid: user.did, targetDid, forumDid: ctx.config.forumDid, action: "space.atbb.modAction.ban", }); } } ); // DELETE /api/mod/ban/:did - Unban a user app.delete( "/ban/:did", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.banUsers"), async (c) => { // Get DID from route param const targetDid = c.req.param("did"); // Validate targetDid format if (!targetDid || !targetDid.startsWith("did:")) { return c.json({ error: "Invalid DID format" }, 400); } // Parse request body const { body, error: parseError } = await safeParseJsonBody(c); if (parseError) return parseError; const { reason } = body; // Validate reason const reasonError = validateReason(reason); if (reasonError) { return c.json({ error: reasonError }, 400); } // Check if target user has membership (404 if not found) try { const [membership] = await ctx.db .select() .from(memberships) .where(eq(memberships.did, targetDid)) .limit(1); if (!membership) { return c.json({ error: "Target user not found" }, 404); } } catch (error) { return handleRouteError(c, error, "Failed to check user membership", { operation: "DELETE /api/mod/ban/:did", logger: ctx.logger, targetDid, }); } // Check if user is already unbanned (not banned) const isBanned = await checkActiveAction( ctx, { did: targetDid }, "space.atbb.modAction.ban" ); // If user is not banned (false) or no actions exist (null), they're already unbanned if (isBanned === false || isBanned === null) { return c.json({ success: true, action: "space.atbb.modAction.unban", targetDid, uri: null, cid: null, alreadyActive: true, }); } // Get ForumAgent const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/mod/ban/:did"); if (agentError) return agentError; // Write unban modAction record to Forum DID's PDS const user = c.get("user")!; const rkey = TID.nextStr(); try { const result = await agent.com.atproto.repo.putRecord({ repo: ctx.config.forumDid, collection: "space.atbb.modAction", rkey, record: { $type: "space.atbb.modAction", action: "space.atbb.modAction.unban", subject: { did: targetDid }, reason: reason.trim(), createdBy: user.did, createdAt: new Date().toISOString(), }, }); return c.json({ success: true, action: "space.atbb.modAction.unban", targetDid, uri: result.data.uri, cid: result.data.cid, alreadyActive: false, }); } catch (error) { return handleRouteError(c, error, "Failed to record moderation action", { operation: "DELETE /api/mod/ban/:did", logger: ctx.logger, moderatorDid: user.did, targetDid, forumDid: ctx.config.forumDid, action: "space.atbb.modAction.unban", }); } } ); // POST /api/mod/lock - Lock a topic (prevent new replies) app.post( "/lock", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.lockTopics"), async (c) => { // Parse request body const { body, error: parseError } = await safeParseJsonBody(c); if (parseError) return parseError; const { topicId, reason } = body; // Validate topicId if (typeof topicId !== "string") { return c.json({ error: "Topic ID is required and must be a string" }, 400); } const topicIdBigInt = parseBigIntParam(topicId); if (topicIdBigInt === null) { return c.json({ error: "Invalid topic ID format" }, 400); } // Validate reason const reasonError = validateReason(reason); if (reasonError) { return c.json({ error: reasonError }, 400); } // Get topic from posts table let topic; try { const [result] = await ctx.db .select() .from(posts) .where(eq(posts.id, topicIdBigInt)) .limit(1); if (!result) { return c.json({ error: "Topic not found" }, 404); } topic = result; } catch (error) { return handleRouteError(c, error, "Failed to check topic", { operation: "POST /api/mod/lock", logger: ctx.logger, topicId, }); } // Validate it's a root post (topic, not reply) if (topic.rootPostId !== null) { return c.json({ error: "Can only lock topic posts, not replies" }, 400); } // Build post URI const postUri = `at://${topic.did}/space.atbb.post/${topic.rkey}`; // Check if already locked const isAlreadyLocked = await checkActiveAction( ctx, { postUri }, "space.atbb.modAction.lock" ); if (isAlreadyLocked === true) { return c.json({ success: true, action: "space.atbb.modAction.lock", topicId: topicId, uri: null, cid: null, alreadyActive: true, }); } // Get ForumAgent const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/mod/lock"); if (agentError) return agentError; // Write modAction record to Forum DID's PDS const user = c.get("user")!; const rkey = TID.nextStr(); try { const result = await agent.com.atproto.repo.putRecord({ repo: ctx.config.forumDid, collection: "space.atbb.modAction", rkey, record: { $type: "space.atbb.modAction", action: "space.atbb.modAction.lock", subject: { post: { uri: postUri, cid: topic.cid, }, }, reason: reason.trim(), createdBy: user.did, createdAt: new Date().toISOString(), }, }); return c.json({ success: true, action: "space.atbb.modAction.lock", topicId: topicId, uri: result.data.uri, cid: result.data.cid, alreadyActive: false, }); } catch (error) { return handleRouteError(c, error, "Failed to record moderation action", { operation: "POST /api/mod/lock", logger: ctx.logger, moderatorDid: user.did, topicId, postUri, forumDid: ctx.config.forumDid, action: "space.atbb.modAction.lock", }); } } ); // DELETE /api/mod/lock/:topicId - Unlock a topic (allow new replies) app.delete( "/lock/:topicId", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.lockTopics"), async (c) => { // Get topicId from route param const topicIdParam = c.req.param("topicId"); // Validate topicId format const topicIdBigInt = parseBigIntParam(topicIdParam); if (topicIdBigInt === null) { return c.json({ error: "Invalid topic ID format" }, 400); } // Parse request body const { body, error: parseError } = await safeParseJsonBody(c); if (parseError) return parseError; const { reason } = body; // Validate reason const reasonError = validateReason(reason); if (reasonError) { return c.json({ error: reasonError }, 400); } // Get topic from posts table let topic; try { const [result] = await ctx.db .select() .from(posts) .where(eq(posts.id, topicIdBigInt)) .limit(1); if (!result) { return c.json({ error: "Topic not found" }, 404); } topic = result; } catch (error) { return handleRouteError(c, error, "Failed to check topic", { operation: "DELETE /api/mod/lock/:topicId", logger: ctx.logger, topicId: topicIdParam, }); } // Validate it's a root post (topic, not reply) if (topic.rootPostId !== null) { return c.json({ error: "Can only unlock topic posts, not replies" }, 400); } // Build post URI const postUri = `at://${topic.did}/space.atbb.post/${topic.rkey}`; // Check if topic is already unlocked (not locked) const isLocked = await checkActiveAction( ctx, { postUri }, "space.atbb.modAction.lock" ); // If topic is not locked (false) or no actions exist (null), it's already unlocked if (isLocked === false || isLocked === null) { return c.json({ success: true, action: "space.atbb.modAction.unlock", topicId: topicIdParam, uri: null, cid: null, alreadyActive: true, }); } // Get ForumAgent const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/mod/lock/:topicId"); if (agentError) return agentError; // Write unlock modAction record to Forum DID's PDS const user = c.get("user")!; const rkey = TID.nextStr(); try { const result = await agent.com.atproto.repo.putRecord({ repo: ctx.config.forumDid, collection: "space.atbb.modAction", rkey, record: { $type: "space.atbb.modAction", action: "space.atbb.modAction.unlock", subject: { post: { uri: postUri, cid: topic.cid, }, }, reason: reason.trim(), createdBy: user.did, createdAt: new Date().toISOString(), }, }); return c.json({ success: true, action: "space.atbb.modAction.unlock", topicId: topicIdParam, uri: result.data.uri, cid: result.data.cid, alreadyActive: false, }); } catch (error) { return handleRouteError(c, error, "Failed to record moderation action", { operation: "DELETE /api/mod/lock/:topicId", logger: ctx.logger, moderatorDid: user.did, topicId: topicIdParam, postUri, forumDid: ctx.config.forumDid, action: "space.atbb.modAction.unlock", }); } } ); /** * POST /api/mod/hide * Hide a post from the forum (soft-delete). * Note: Uses "space.atbb.modAction.delete" action type. The read-path logic * determines the current state by looking at the most recent action chronologically. * Unhide uses a separate "space.atbb.modAction.undelete" action type. */ app.post( "/hide", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.moderatePosts"), async (c) => { const user = c.get("user")!; // Parse request body const { body, error: parseError } = await safeParseJsonBody(c); if (parseError) return parseError; const { postId, reason } = body; // Validate postId if (typeof postId !== "string") { return c.json({ error: "postId is required and must be a string" }, 400); } const postIdBigInt = parseBigIntParam(postId); if (postIdBigInt === null) { return c.json({ error: "Invalid post ID" }, 400); } // Validate reason const reasonError = validateReason(reason); if (reasonError) { return c.json({ error: reasonError }, 400); } // Get post (can be topic or reply) let post; try { const [result] = await ctx.db .select() .from(posts) .where(eq(posts.id, postIdBigInt)) .limit(1); if (!result) { return c.json({ error: "Post not found" }, 404); } post = result; } catch (error) { return handleRouteError(c, error, "Failed to retrieve post", { operation: "POST /api/mod/hide", logger: ctx.logger, postId, }); } const postUri = `at://${post.did}/space.atbb.post/${post.rkey}`; // Check if post is already hidden const isHidden = await checkActiveAction( ctx, { postUri }, "space.atbb.modAction.delete" ); if (isHidden) { return c.json({ success: true, action: "space.atbb.modAction.delete", postId: postId, postUri: postUri, uri: null, cid: null, alreadyActive: true, }, 200); } // Get ForumAgent const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/mod/hide"); if (agentError) return agentError; // Write hide modAction record (action type is "delete" per lexicon) try { const rkey = TID.nextStr(); const result = await agent.com.atproto.repo.putRecord({ repo: ctx.config.forumDid, collection: "space.atbb.modAction", rkey, record: { $type: "space.atbb.modAction", action: "space.atbb.modAction.delete", subject: { post: { uri: postUri, cid: post.cid, }, }, reason: reason.trim(), createdBy: user.did, createdAt: new Date().toISOString(), }, }); return c.json({ success: true, action: "space.atbb.modAction.delete", postId: postId, postUri: postUri, uri: result.data.uri, cid: result.data.cid, alreadyActive: false, }, 200); } catch (error) { return handleRouteError(c, error, "Failed to record moderation action", { operation: "POST /api/mod/hide", logger: ctx.logger, moderatorDid: user.did, postId, postUri, forumDid: ctx.config.forumDid, action: "space.atbb.modAction.delete", }); } } ); /** * DELETE /api/mod/hide/:postId * Unhide a post (reversal action). * Note: Uses "space.atbb.modAction.undelete" action type to reverse hide. * The read-path logic determines the current state by looking at the most * recent action chronologically (alternating hide/unhide creates a toggle effect). */ app.delete( "/hide/:postId", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.moderatePosts"), async (c) => { const user = c.get("user")!; const postIdParam = c.req.param("postId"); const postIdBigInt = parseBigIntParam(postIdParam); if (postIdBigInt === null) { return c.json({ error: "Invalid post ID" }, 400); } // Parse request body const { body, error: parseError } = await safeParseJsonBody(c); if (parseError) return parseError; const { reason } = body; // Validate reason const reasonError = validateReason(reason); if (reasonError) { return c.json({ error: reasonError }, 400); } // Get post let post; try { const [result] = await ctx.db .select() .from(posts) .where(eq(posts.id, postIdBigInt)) .limit(1); if (!result) { return c.json({ error: "Post not found" }, 404); } post = result; } catch (error) { return handleRouteError(c, error, "Failed to retrieve post", { operation: "DELETE /api/mod/hide/:postId", logger: ctx.logger, postId: postIdParam, }); } const postUri = `at://${post.did}/space.atbb.post/${post.rkey}`; // Check if post is currently hidden const isHidden = await checkActiveAction( ctx, { postUri }, "space.atbb.modAction.delete" ); if (isHidden === false || isHidden === null) { return c.json({ success: true, action: "space.atbb.modAction.undelete", postId: postIdParam, postUri: postUri, uri: null, cid: null, alreadyActive: true, }, 200); } // Get ForumAgent const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/mod/hide/:postId"); if (agentError) return agentError; // Write unhide modAction record // Uses "undelete" action type for reversal (hide→unhide toggle) try { const rkey = TID.nextStr(); const result = await agent.com.atproto.repo.putRecord({ repo: ctx.config.forumDid, collection: "space.atbb.modAction", rkey, record: { $type: "space.atbb.modAction", action: "space.atbb.modAction.undelete", subject: { post: { uri: postUri, cid: post.cid, }, }, reason: reason.trim(), createdBy: user.did, createdAt: new Date().toISOString(), }, }); return c.json({ success: true, action: "space.atbb.modAction.undelete", postId: postIdParam, postUri: postUri, uri: result.data.uri, cid: result.data.cid, alreadyActive: false, }, 200); } catch (error) { return handleRouteError(c, error, "Failed to record moderation action", { operation: "DELETE /api/mod/hide/:postId", logger: ctx.logger, moderatorDid: user.did, postId: postIdParam, postUri, forumDid: ctx.config.forumDid, action: "space.atbb.modAction.undelete", }); } } ); return app; }