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

docs: missed committing a plan

+2230
+2230
docs/plans/2026-02-15-moderation-endpoints-implementation.md
···
··· 1 + # Moderation Action Write-Path API Endpoints Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Implement API endpoints for moderators to ban users, lock topics, and hide posts by writing modAction records to the Forum DID's PDS. 6 + 7 + **Architecture:** Single route file (`mod.ts`) with 6 endpoints (ban/lock/hide + reversals). Each endpoint follows auth → permission → validation → duplicate check → PDS write pattern. ForumAgent writes modAction records to Forum DID's PDS, firehose indexes them asynchronously. 8 + 9 + **Tech Stack:** Hono (routes), Drizzle ORM (database), @atproto/api (PDS writes), Vitest (tests) 10 + 11 + **Design Reference:** `docs/plans/2026-02-15-moderation-endpoints-design.md` 12 + 13 + --- 14 + 15 + ## Task 1: Add Moderation Permissions to Default Roles 16 + 17 + **Files:** 18 + - Modify: `apps/appview/src/lib/seed-roles.ts` 19 + 20 + **Step 1: Read existing seed-roles.ts to understand structure** 21 + 22 + Read the file to see how default roles are defined. 23 + 24 + **Step 2: Add moderation permissions to roles** 25 + 26 + Update the role definitions in `seedDefaultRoles()`: 27 + 28 + ```typescript 29 + // Admin role (after manageRoles permission) 30 + { 31 + name: "Admin", 32 + permissions: [ 33 + "space.atbb.permission.manageRoles", 34 + "space.atbb.permission.banUsers", // NEW 35 + "space.atbb.permission.lockTopics", // NEW 36 + "space.atbb.permission.moderatePosts", // NEW 37 + ], 38 + priority: 10, 39 + } 40 + 41 + // Moderator role (after existing permissions if any, otherwise create) 42 + { 43 + name: "Moderator", 44 + permissions: [ 45 + "space.atbb.permission.lockTopics", // NEW 46 + "space.atbb.permission.moderatePosts", // NEW 47 + ], 48 + priority: 20, 49 + } 50 + ``` 51 + 52 + **Step 3: Commit permission updates** 53 + 54 + ```bash 55 + git add apps/appview/src/lib/seed-roles.ts 56 + git commit -m "feat(permissions): add moderation permissions to default roles (ATB-19) 57 + 58 + - Admin: banUsers, lockTopics, moderatePosts 59 + - Moderator: lockTopics, moderatePosts" 60 + ``` 61 + 62 + --- 63 + 64 + ## Task 2: Create Mod Routes File Structure 65 + 66 + **Files:** 67 + - Create: `apps/appview/src/routes/mod.ts` 68 + - Create: `apps/appview/src/routes/__tests__/mod.test.ts` 69 + 70 + **Step 1: Create empty mod.ts route file** 71 + 72 + Create the file with factory function skeleton: 73 + 74 + ```typescript 75 + import { Hono } from "hono"; 76 + import type { AppContext } from "../lib/app-context.js"; 77 + import type { Variables } from "../types.js"; 78 + import { requireAuth } from "../middleware/auth.js"; 79 + import { requirePermission } from "../middleware/permissions.js"; 80 + import { modActions, users, memberships, posts } from "@atbb/db"; 81 + import { eq, desc, and } from "drizzle-orm"; 82 + import { isNetworkError } from "./helpers.js"; 83 + import { TID } from "@atproto/common"; 84 + 85 + export function createModRoutes(ctx: AppContext) { 86 + const app = new Hono<{ Variables: Variables }>(); 87 + 88 + // Routes will go here 89 + 90 + return app; 91 + } 92 + ``` 93 + 94 + **Step 2: Register mod routes in main app** 95 + 96 + Modify: `apps/appview/src/routes/index.ts` 97 + 98 + Add import and route registration: 99 + 100 + ```typescript 101 + import { createModRoutes } from "./mod.js"; 102 + 103 + // In createApiRoutes(): 104 + app.route("/mod", createModRoutes(ctx)); 105 + ``` 106 + 107 + **Step 3: Create empty test file** 108 + 109 + Create: `apps/appview/src/routes/__tests__/mod.test.ts` 110 + 111 + ```typescript 112 + import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 113 + import { createTestContext, destroyTestContext } from "../../lib/__tests__/test-context.js"; 114 + import type { TestContext } from "../../lib/__tests__/test-context.js"; 115 + import { createApp } from "../../create-app.js"; 116 + 117 + describe("createModRoutes", () => { 118 + let testCtx: TestContext; 119 + let app: ReturnType<typeof createApp>; 120 + 121 + beforeEach(async () => { 122 + testCtx = await createTestContext(); 123 + app = createApp(testCtx.ctx); 124 + }); 125 + 126 + afterEach(async () => { 127 + await destroyTestContext(testCtx); 128 + }); 129 + 130 + // Tests will go here 131 + }); 132 + ``` 133 + 134 + **Step 4: Commit skeleton** 135 + 136 + ```bash 137 + git add apps/appview/src/routes/mod.ts apps/appview/src/routes/__tests__/mod.test.ts apps/appview/src/routes/index.ts 138 + git commit -m "feat(mod): add mod routes skeleton (ATB-19)" 139 + ``` 140 + 141 + --- 142 + 143 + ## Task 3: Helper Function - Validate Reason Field 144 + 145 + **Files:** 146 + - Modify: `apps/appview/src/routes/mod.ts` 147 + - Modify: `apps/appview/src/routes/__tests__/mod.test.ts` 148 + 149 + **Step 1: Write failing test for reason validation** 150 + 151 + Add to mod.test.ts: 152 + 153 + ```typescript 154 + describe("Helper: validateReason", () => { 155 + it("returns null for valid reason", () => { 156 + const { validateReason } = await import("../mod.js"); 157 + expect(validateReason("Spam")).toBeNull(); 158 + }); 159 + 160 + it("returns error for non-string reason", () => { 161 + const { validateReason } = await import("../mod.js"); 162 + expect(validateReason(123 as any)).toBe("Reason is required and must be a string"); 163 + }); 164 + 165 + it("returns error for empty reason", () => { 166 + const { validateReason } = await import("../mod.js"); 167 + expect(validateReason("")).toBe("Reason is required and must not be empty"); 168 + expect(validateReason(" ")).toBe("Reason is required and must not be empty"); 169 + }); 170 + 171 + it("returns error for reason exceeding 3000 characters", () => { 172 + const { validateReason } = await import("../mod.js"); 173 + const longReason = "x".repeat(3001); 174 + expect(validateReason(longReason)).toBe("Reason must not exceed 3000 characters"); 175 + }); 176 + }); 177 + ``` 178 + 179 + **Step 2: Run test to verify it fails** 180 + 181 + Run: `pnpm --filter @atbb/appview test mod.test.ts -t "validateReason"` 182 + 183 + Expected: FAIL - validateReason not exported 184 + 185 + **Step 3: Implement validateReason helper** 186 + 187 + Add to mod.ts before `createModRoutes`: 188 + 189 + ```typescript 190 + /** 191 + * Validate reason field (required, 1-3000 chars). 192 + * @returns null if valid, error message string if invalid 193 + */ 194 + export function validateReason(reason: unknown): string | null { 195 + if (typeof reason !== "string") { 196 + return "Reason is required and must be a string"; 197 + } 198 + 199 + if (reason.trim().length === 0) { 200 + return "Reason is required and must not be empty"; 201 + } 202 + 203 + if (reason.length > 3000) { 204 + return "Reason must not exceed 3000 characters"; 205 + } 206 + 207 + return null; 208 + } 209 + ``` 210 + 211 + **Step 4: Run test to verify it passes** 212 + 213 + Run: `pnpm --filter @atbb/appview test mod.test.ts -t "validateReason"` 214 + 215 + Expected: PASS (4 tests) 216 + 217 + **Step 5: Commit** 218 + 219 + ```bash 220 + git add apps/appview/src/routes/mod.ts apps/appview/src/routes/__tests__/mod.test.ts 221 + git commit -m "feat(mod): add reason validation helper (ATB-19) 222 + 223 + Validates reason field: required, non-empty, max 3000 chars" 224 + ``` 225 + 226 + --- 227 + 228 + ## Task 4: Helper Function - Check Active Action 229 + 230 + **Files:** 231 + - Modify: `apps/appview/src/routes/mod.ts` 232 + - Modify: `apps/appview/src/routes/__tests__/mod.test.ts` 233 + 234 + **Step 1: Write failing test for checkActiveAction** 235 + 236 + Add to mod.test.ts: 237 + 238 + ```typescript 239 + describe("Helper: checkActiveAction", () => { 240 + it("returns null when no actions exist", async () => { 241 + const { checkActiveAction } = await import("../mod.js"); 242 + const result = await checkActiveAction( 243 + testCtx.ctx, 244 + { did: "did:plc:test" }, 245 + "space.atbb.modAction.ban" 246 + ); 247 + expect(result).toBeNull(); 248 + }); 249 + 250 + it("returns true when action is active (most recent)", async () => { 251 + const { checkActiveAction } = await import("../mod.js"); 252 + 253 + // Insert ban action 254 + await testCtx.ctx.db.insert(modActions).values({ 255 + did: testCtx.ctx.config.forumDid, 256 + rkey: "test1", 257 + cid: "bafytest1", 258 + action: "space.atbb.modAction.ban", 259 + subjectDid: "did:plc:test", 260 + reason: "Test", 261 + createdBy: "did:plc:admin", 262 + createdAt: new Date(), 263 + indexedAt: new Date(), 264 + }); 265 + 266 + const result = await checkActiveAction( 267 + testCtx.ctx, 268 + { did: "did:plc:test" }, 269 + "space.atbb.modAction.ban" 270 + ); 271 + expect(result).toBe(true); 272 + }); 273 + 274 + it("returns false when action is reversed (unban after ban)", async () => { 275 + const { checkActiveAction } = await import("../mod.js"); 276 + 277 + // Insert ban 278 + await testCtx.ctx.db.insert(modActions).values({ 279 + did: testCtx.ctx.config.forumDid, 280 + rkey: "test1", 281 + cid: "bafytest1", 282 + action: "space.atbb.modAction.ban", 283 + subjectDid: "did:plc:test", 284 + reason: "Test", 285 + createdBy: "did:plc:admin", 286 + createdAt: new Date("2024-01-01"), 287 + indexedAt: new Date("2024-01-01"), 288 + }); 289 + 290 + // Insert unban (more recent) 291 + await testCtx.ctx.db.insert(modActions).values({ 292 + did: testCtx.ctx.config.forumDid, 293 + rkey: "test2", 294 + cid: "bafytest2", 295 + action: "space.atbb.modAction.unban", 296 + subjectDid: "did:plc:test", 297 + reason: "Appeal", 298 + createdBy: "did:plc:admin", 299 + createdAt: new Date("2024-01-02"), 300 + indexedAt: new Date("2024-01-02"), 301 + }); 302 + 303 + const result = await checkActiveAction( 304 + testCtx.ctx, 305 + { did: "did:plc:test" }, 306 + "space.atbb.modAction.ban" 307 + ); 308 + expect(result).toBe(false); 309 + }); 310 + }); 311 + ``` 312 + 313 + **Step 2: Run test to verify it fails** 314 + 315 + Run: `pnpm --filter @atbb/appview test mod.test.ts -t "checkActiveAction"` 316 + 317 + Expected: FAIL - checkActiveAction not exported 318 + 319 + **Step 3: Implement checkActiveAction helper** 320 + 321 + Add to mod.ts: 322 + 323 + ```typescript 324 + /** 325 + * Subject reference for modAction (user DID or post URI). 326 + */ 327 + type ModSubject = { did: string } | { postUri: string }; 328 + 329 + /** 330 + * Check if a specific moderation action is currently active. 331 + * @returns true if active, false if reversed/inactive, null if no actions exist 332 + */ 333 + export async function checkActiveAction( 334 + ctx: AppContext, 335 + subject: ModSubject, 336 + actionType: string 337 + ): Promise<boolean | null> { 338 + try { 339 + // Build where clause based on subject type 340 + const whereClause = "did" in subject 341 + ? eq(modActions.subjectDid, subject.did) 342 + : eq(modActions.subjectPostUri, subject.postUri); 343 + 344 + // Get most recent action for this subject 345 + const [mostRecent] = await ctx.db 346 + .select() 347 + .from(modActions) 348 + .where(whereClause) 349 + .orderBy(desc(modActions.createdAt)) 350 + .limit(1); 351 + 352 + if (!mostRecent) { 353 + return null; // No actions exist 354 + } 355 + 356 + // Check if most recent action matches the action type 357 + return mostRecent.action === actionType; 358 + } catch (error) { 359 + console.error("Failed to check active action", { 360 + subject, 361 + actionType, 362 + error: error instanceof Error ? error.message : String(error), 363 + }); 364 + return null; // Fail safe: treat as no active action 365 + } 366 + } 367 + ``` 368 + 369 + **Step 4: Run test to verify it passes** 370 + 371 + Run: `pnpm --filter @atbb/appview test mod.test.ts -t "checkActiveAction"` 372 + 373 + Expected: PASS (3 tests) 374 + 375 + **Step 5: Commit** 376 + 377 + ```bash 378 + git add apps/appview/src/routes/mod.ts apps/appview/src/routes/__tests__/mod.test.ts 379 + git commit -m "feat(mod): add checkActiveAction helper (ATB-19) 380 + 381 + Queries most recent modAction for a subject to determine if action is active" 382 + ``` 383 + 384 + --- 385 + 386 + ## Task 5: Implement POST /api/mod/ban Endpoint 387 + 388 + **Files:** 389 + - Modify: `apps/appview/src/routes/mod.ts` 390 + - Modify: `apps/appview/src/routes/__tests__/mod.test.ts` 391 + 392 + **Step 1: Write failing test for ban endpoint** 393 + 394 + Add to mod.test.ts: 395 + 396 + ```typescript 397 + describe("POST /api/mod/ban", () => { 398 + it("bans user successfully when admin has permission", async () => { 399 + const admin = await testCtx.createUser("Admin"); 400 + const member = await testCtx.createUser("Member"); 401 + 402 + const mockPutRecord = vi.fn().mockResolvedValue({ 403 + uri: "at://did:plc:forum/space.atbb.modAction/abc123", 404 + cid: "bafytest", 405 + }); 406 + 407 + testCtx.ctx.forumAgent = { 408 + isAuthenticated: () => true, 409 + getAgent: () => ({ 410 + com: { atproto: { repo: { putRecord: mockPutRecord } } }, 411 + }), 412 + } as any; 413 + 414 + const res = await app.request("/api/mod/ban", { 415 + method: "POST", 416 + headers: { Cookie: `atbb_session=${admin.sessionToken}` }, 417 + body: JSON.stringify({ 418 + targetDid: member.did, 419 + reason: "Spam and harassment", 420 + }), 421 + }); 422 + 423 + expect(res.status).toBe(200); 424 + const data = await res.json(); 425 + expect(data.success).toBe(true); 426 + expect(data.action).toBe("ban"); 427 + expect(data.targetDid).toBe(member.did); 428 + expect(data.uri).toBe("at://did:plc:forum/space.atbb.modAction/abc123"); 429 + expect(data.alreadyActive).toBe(false); 430 + 431 + // Verify PDS write 432 + expect(mockPutRecord).toHaveBeenCalledWith({ 433 + repo: testCtx.ctx.config.forumDid, 434 + collection: "space.atbb.modAction", 435 + rkey: expect.any(String), 436 + record: { 437 + $type: "space.atbb.modAction", 438 + action: "space.atbb.modAction.ban", 439 + subject: { did: member.did }, 440 + reason: "Spam and harassment", 441 + createdBy: admin.did, 442 + createdAt: expect.any(String), 443 + }, 444 + }); 445 + }); 446 + }); 447 + ``` 448 + 449 + **Step 2: Run test to verify it fails** 450 + 451 + Run: `pnpm --filter @atbb/appview test mod.test.ts -t "POST /api/mod/ban"` 452 + 453 + Expected: FAIL - 404 Not Found (route doesn't exist) 454 + 455 + **Step 3: Implement POST /api/mod/ban endpoint** 456 + 457 + Add to mod.ts in `createModRoutes`: 458 + 459 + ```typescript 460 + /** 461 + * POST /api/mod/ban 462 + * Ban a user from the forum. 463 + */ 464 + app.post( 465 + "/ban", 466 + requireAuth(ctx), 467 + requirePermission(ctx, "space.atbb.permission.banUsers"), 468 + async (c) => { 469 + const user = c.get("user")!; 470 + 471 + // Parse request body 472 + let body: any; 473 + try { 474 + body = await c.req.json(); 475 + } catch { 476 + return c.json({ error: "Invalid JSON in request body" }, 400); 477 + } 478 + 479 + const { targetDid, reason } = body; 480 + 481 + // Validate targetDid 482 + if (typeof targetDid !== "string" || !targetDid.startsWith("did:")) { 483 + return c.json({ error: "Invalid DID format" }, 400); 484 + } 485 + 486 + // Validate reason 487 + const reasonError = validateReason(reason); 488 + if (reasonError) { 489 + return c.json({ error: reasonError }, 400); 490 + } 491 + 492 + // Check target user exists (has membership) 493 + const [membership] = await ctx.db 494 + .select() 495 + .from(memberships) 496 + .where(eq(memberships.did, targetDid)) 497 + .limit(1); 498 + 499 + if (!membership) { 500 + return c.json({ error: "User is not a member of this forum" }, 404); 501 + } 502 + 503 + // Check if user is already banned 504 + const isActive = await checkActiveAction( 505 + ctx, 506 + { did: targetDid }, 507 + "space.atbb.modAction.ban" 508 + ); 509 + 510 + if (isActive) { 511 + return c.json({ 512 + success: true, 513 + action: "ban", 514 + targetDid, 515 + alreadyActive: true, 516 + }, 200); 517 + } 518 + 519 + // Get ForumAgent 520 + if (!ctx.forumAgent) { 521 + return c.json({ 522 + error: "Forum agent not available. Server configuration issue.", 523 + }, 500); 524 + } 525 + 526 + const agent = ctx.forumAgent.getAgent(); 527 + if (!agent) { 528 + return c.json({ 529 + error: "Forum agent not authenticated. Please try again later.", 530 + }, 503); 531 + } 532 + 533 + // Write modAction record to Forum DID's PDS 534 + try { 535 + const result = await agent.com.atproto.repo.putRecord({ 536 + repo: ctx.config.forumDid, 537 + collection: "space.atbb.modAction", 538 + rkey: TID.nextStr(), 539 + record: { 540 + $type: "space.atbb.modAction", 541 + action: "space.atbb.modAction.ban", 542 + subject: { did: targetDid }, 543 + reason, 544 + createdBy: user.did, 545 + createdAt: new Date().toISOString(), 546 + }, 547 + }); 548 + 549 + return c.json({ 550 + success: true, 551 + action: "ban", 552 + targetDid, 553 + uri: result.uri, 554 + cid: result.cid, 555 + alreadyActive: false, 556 + }, 200); 557 + } catch (error) { 558 + console.error("Failed to write ban modAction", { 559 + operation: "POST /api/mod/ban", 560 + targetDid, 561 + error: error instanceof Error ? error.message : String(error), 562 + }); 563 + 564 + if (error instanceof Error && isNetworkError(error)) { 565 + return c.json({ 566 + error: "Unable to reach Forum PDS. Please try again later.", 567 + }, 503); 568 + } 569 + 570 + return c.json({ 571 + error: "Failed to record moderation action. Please contact support.", 572 + }, 500); 573 + } 574 + } 575 + ); 576 + ``` 577 + 578 + **Step 4: Run test to verify it passes** 579 + 580 + Run: `pnpm --filter @atbb/appview test mod.test.ts -t "POST /api/mod/ban.*successfully"` 581 + 582 + Expected: PASS 583 + 584 + **Step 5: Commit** 585 + 586 + ```bash 587 + git add apps/appview/src/routes/mod.ts apps/appview/src/routes/__tests__/mod.test.ts 588 + git commit -m "feat(mod): implement POST /api/mod/ban endpoint (ATB-19) 589 + 590 + Bans user by writing modAction record to Forum DID's PDS" 591 + ``` 592 + 593 + --- 594 + 595 + ## Task 6: Add Error Tests for POST /api/mod/ban 596 + 597 + **Files:** 598 + - Modify: `apps/appview/src/routes/__tests__/mod.test.ts` 599 + 600 + **Step 1: Write authorization error tests** 601 + 602 + Add to "POST /api/mod/ban" describe block: 603 + 604 + ```typescript 605 + it("returns 401 when not authenticated", async () => { 606 + const res = await app.request("/api/mod/ban", { 607 + method: "POST", 608 + body: JSON.stringify({ targetDid: "did:plc:test", reason: "Test" }), 609 + }); 610 + 611 + expect(res.status).toBe(401); 612 + }); 613 + 614 + it("returns 403 when user lacks banUsers permission", async () => { 615 + const member = await testCtx.createUser("Member"); // No ban permission 616 + 617 + const res = await app.request("/api/mod/ban", { 618 + method: "POST", 619 + headers: { Cookie: `atbb_session=${member.sessionToken}` }, 620 + body: JSON.stringify({ targetDid: "did:plc:other", reason: "Test" }), 621 + }); 622 + 623 + expect(res.status).toBe(403); 624 + const data = await res.json(); 625 + expect(data.error).toContain("Insufficient permissions"); 626 + }); 627 + ``` 628 + 629 + **Step 2: Write input validation tests** 630 + 631 + ```typescript 632 + it("returns 400 for invalid DID format", async () => { 633 + const admin = await testCtx.createUser("Admin"); 634 + 635 + const res = await app.request("/api/mod/ban", { 636 + method: "POST", 637 + headers: { Cookie: `atbb_session=${admin.sessionToken}` }, 638 + body: JSON.stringify({ targetDid: "invalid", reason: "Test" }), 639 + }); 640 + 641 + expect(res.status).toBe(400); 642 + const data = await res.json(); 643 + expect(data.error).toContain("Invalid DID format"); 644 + }); 645 + 646 + it("returns 400 for missing reason", async () => { 647 + const admin = await testCtx.createUser("Admin"); 648 + 649 + const res = await app.request("/api/mod/ban", { 650 + method: "POST", 651 + headers: { Cookie: `atbb_session=${admin.sessionToken}` }, 652 + body: JSON.stringify({ targetDid: "did:plc:test" }), 653 + }); 654 + 655 + expect(res.status).toBe(400); 656 + const data = await res.json(); 657 + expect(data.error).toContain("Reason is required"); 658 + }); 659 + 660 + it("returns 400 for empty reason", async () => { 661 + const admin = await testCtx.createUser("Admin"); 662 + 663 + const res = await app.request("/api/mod/ban", { 664 + method: "POST", 665 + headers: { Cookie: `atbb_session=${admin.sessionToken}` }, 666 + body: JSON.stringify({ targetDid: "did:plc:test", reason: " " }), 667 + }); 668 + 669 + expect(res.status).toBe(400); 670 + const data = await res.json(); 671 + expect(data.error).toContain("must not be empty"); 672 + }); 673 + 674 + it("returns 400 for malformed JSON", async () => { 675 + const admin = await testCtx.createUser("Admin"); 676 + 677 + const res = await app.request("/api/mod/ban", { 678 + method: "POST", 679 + headers: { 680 + Cookie: `atbb_session=${admin.sessionToken}`, 681 + "Content-Type": "application/json", 682 + }, 683 + body: "{ invalid json }", 684 + }); 685 + 686 + expect(res.status).toBe(400); 687 + const data = await res.json(); 688 + expect(data.error).toContain("Invalid JSON"); 689 + }); 690 + ``` 691 + 692 + **Step 3: Write target validation test** 693 + 694 + ```typescript 695 + it("returns 404 when target user has no membership", async () => { 696 + const admin = await testCtx.createUser("Admin"); 697 + 698 + const res = await app.request("/api/mod/ban", { 699 + method: "POST", 700 + headers: { Cookie: `atbb_session=${admin.sessionToken}` }, 701 + body: JSON.stringify({ 702 + targetDid: "did:plc:nonexistent", 703 + reason: "Test", 704 + }), 705 + }); 706 + 707 + expect(res.status).toBe(404); 708 + const data = await res.json(); 709 + expect(data.error).toContain("not a member"); 710 + }); 711 + ``` 712 + 713 + **Step 4: Write idempotency test** 714 + 715 + ```typescript 716 + it("returns alreadyActive: true when user already banned", async () => { 717 + const admin = await testCtx.createUser("Admin"); 718 + const member = await testCtx.createUser("Member"); 719 + 720 + // Insert existing ban action 721 + await testCtx.ctx.db.insert(modActions).values({ 722 + did: testCtx.ctx.config.forumDid, 723 + rkey: "existing", 724 + cid: "bafyexisting", 725 + action: "space.atbb.modAction.ban", 726 + subjectDid: member.did, 727 + reason: "Previous ban", 728 + createdBy: admin.did, 729 + createdAt: new Date(), 730 + indexedAt: new Date(), 731 + }); 732 + 733 + const mockPutRecord = vi.fn(); 734 + testCtx.ctx.forumAgent = { 735 + isAuthenticated: () => true, 736 + getAgent: () => ({ 737 + com: { atproto: { repo: { putRecord: mockPutRecord } } }, 738 + }), 739 + } as any; 740 + 741 + const res = await app.request("/api/mod/ban", { 742 + method: "POST", 743 + headers: { Cookie: `atbb_session=${admin.sessionToken}` }, 744 + body: JSON.stringify({ targetDid: member.did, reason: "Duplicate ban" }), 745 + }); 746 + 747 + expect(res.status).toBe(200); 748 + const data = await res.json(); 749 + expect(data.alreadyActive).toBe(true); 750 + expect(mockPutRecord).not.toHaveBeenCalled(); // No duplicate write 751 + }); 752 + ``` 753 + 754 + **Step 5: Write error handling tests** 755 + 756 + ```typescript 757 + it("returns 500 when ForumAgent not available", async () => { 758 + const admin = await testCtx.createUser("Admin"); 759 + const member = await testCtx.createUser("Member"); 760 + 761 + testCtx.ctx.forumAgent = null; // Simulate unavailable agent 762 + 763 + const res = await app.request("/api/mod/ban", { 764 + method: "POST", 765 + headers: { Cookie: `atbb_session=${admin.sessionToken}` }, 766 + body: JSON.stringify({ targetDid: member.did, reason: "Test" }), 767 + }); 768 + 769 + expect(res.status).toBe(500); 770 + const data = await res.json(); 771 + expect(data.error).toContain("Forum agent not available"); 772 + }); 773 + 774 + it("returns 503 when ForumAgent not authenticated", async () => { 775 + const admin = await testCtx.createUser("Admin"); 776 + const member = await testCtx.createUser("Member"); 777 + 778 + testCtx.ctx.forumAgent = { 779 + isAuthenticated: () => false, 780 + getAgent: () => null, 781 + } as any; 782 + 783 + const res = await app.request("/api/mod/ban", { 784 + method: "POST", 785 + headers: { Cookie: `atbb_session=${admin.sessionToken}` }, 786 + body: JSON.stringify({ targetDid: member.did, reason: "Test" }), 787 + }); 788 + 789 + expect(res.status).toBe(503); 790 + const data = await res.json(); 791 + expect(data.error).toContain("not authenticated"); 792 + }); 793 + 794 + it("returns 503 for network errors writing to PDS", async () => { 795 + const admin = await testCtx.createUser("Admin"); 796 + const member = await testCtx.createUser("Member"); 797 + 798 + const mockPutRecord = vi.fn().mockRejectedValue(new Error("fetch failed")); 799 + testCtx.ctx.forumAgent = { 800 + isAuthenticated: () => true, 801 + getAgent: () => ({ 802 + com: { atproto: { repo: { putRecord: mockPutRecord } } }, 803 + }), 804 + } as any; 805 + 806 + const res = await app.request("/api/mod/ban", { 807 + method: "POST", 808 + headers: { Cookie: `atbb_session=${admin.sessionToken}` }, 809 + body: JSON.stringify({ targetDid: member.did, reason: "Test" }), 810 + }); 811 + 812 + expect(res.status).toBe(503); 813 + const data = await res.json(); 814 + expect(data.error).toContain("try again later"); 815 + }); 816 + 817 + it("returns 500 for unexpected errors writing to PDS", async () => { 818 + const admin = await testCtx.createUser("Admin"); 819 + const member = await testCtx.createUser("Member"); 820 + 821 + const mockPutRecord = vi.fn().mockRejectedValue(new Error("Database error")); 822 + testCtx.ctx.forumAgent = { 823 + isAuthenticated: () => true, 824 + getAgent: () => ({ 825 + com: { atproto: { repo: { putRecord: mockPutRecord } } }, 826 + }), 827 + } as any; 828 + 829 + const res = await app.request("/api/mod/ban", { 830 + method: "POST", 831 + headers: { Cookie: `atbb_session=${admin.sessionToken}` }, 832 + body: JSON.stringify({ targetDid: member.did, reason: "Test" }), 833 + }); 834 + 835 + expect(res.status).toBe(500); 836 + const data = await res.json(); 837 + expect(data.error).toContain("contact support"); 838 + }); 839 + ``` 840 + 841 + **Step 6: Run all ban endpoint tests** 842 + 843 + Run: `pnpm --filter @atbb/appview test mod.test.ts -t "POST /api/mod/ban"` 844 + 845 + Expected: PASS (~13 tests) 846 + 847 + **Step 7: Commit** 848 + 849 + ```bash 850 + git add apps/appview/src/routes/__tests__/mod.test.ts 851 + git commit -m "test(mod): add comprehensive tests for POST /api/mod/ban (ATB-19) 852 + 853 + Covers auth, validation, idempotency, and error classification" 854 + ``` 855 + 856 + --- 857 + 858 + ## Task 7: Implement DELETE /api/mod/ban/:did (Unban) 859 + 860 + **Files:** 861 + - Modify: `apps/appview/src/routes/mod.ts` 862 + - Modify: `apps/appview/src/routes/__tests__/mod.test.ts` 863 + 864 + **Step 1: Write failing test for unban endpoint** 865 + 866 + Add to mod.test.ts: 867 + 868 + ```typescript 869 + describe("DELETE /api/mod/ban/:did", () => { 870 + it("unbans user successfully", async () => { 871 + const admin = await testCtx.createUser("Admin"); 872 + const member = await testCtx.createUser("Member"); 873 + 874 + // Insert existing ban 875 + await testCtx.ctx.db.insert(modActions).values({ 876 + did: testCtx.ctx.config.forumDid, 877 + rkey: "ban1", 878 + cid: "bafyban", 879 + action: "space.atbb.modAction.ban", 880 + subjectDid: member.did, 881 + reason: "Original ban", 882 + createdBy: admin.did, 883 + createdAt: new Date(), 884 + indexedAt: new Date(), 885 + }); 886 + 887 + const mockPutRecord = vi.fn().mockResolvedValue({ 888 + uri: "at://did:plc:forum/space.atbb.modAction/unban123", 889 + cid: "bafyunban", 890 + }); 891 + 892 + testCtx.ctx.forumAgent = { 893 + isAuthenticated: () => true, 894 + getAgent: () => ({ 895 + com: { atproto: { repo: { putRecord: mockPutRecord } } }, 896 + }), 897 + } as any; 898 + 899 + const res = await app.request(`/api/mod/ban/${member.did}`, { 900 + method: "DELETE", 901 + headers: { Cookie: `atbb_session=${admin.sessionToken}` }, 902 + body: JSON.stringify({ reason: "Appeal approved" }), 903 + }); 904 + 905 + expect(res.status).toBe(200); 906 + const data = await res.json(); 907 + expect(data.success).toBe(true); 908 + expect(data.action).toBe("unban"); 909 + expect(data.targetDid).toBe(member.did); 910 + expect(data.alreadyActive).toBe(false); 911 + 912 + // Verify PDS write 913 + expect(mockPutRecord).toHaveBeenCalledWith({ 914 + repo: testCtx.ctx.config.forumDid, 915 + collection: "space.atbb.modAction", 916 + rkey: expect.any(String), 917 + record: { 918 + $type: "space.atbb.modAction", 919 + action: "space.atbb.modAction.unban", 920 + subject: { did: member.did }, 921 + reason: "Appeal approved", 922 + createdBy: admin.did, 923 + createdAt: expect.any(String), 924 + }, 925 + }); 926 + }); 927 + 928 + it("returns alreadyActive: true when user already unbanned", async () => { 929 + const admin = await testCtx.createUser("Admin"); 930 + const member = await testCtx.createUser("Member"); 931 + 932 + // Insert unban (most recent) 933 + await testCtx.ctx.db.insert(modActions).values({ 934 + did: testCtx.ctx.config.forumDid, 935 + rkey: "unban1", 936 + cid: "bafyunban", 937 + action: "space.atbb.modAction.unban", 938 + subjectDid: member.did, 939 + reason: "Previous unban", 940 + createdBy: admin.did, 941 + createdAt: new Date(), 942 + indexedAt: new Date(), 943 + }); 944 + 945 + const mockPutRecord = vi.fn(); 946 + testCtx.ctx.forumAgent = { 947 + isAuthenticated: () => true, 948 + getAgent: () => ({ 949 + com: { atproto: { repo: { putRecord: mockPutRecord } } }, 950 + }), 951 + } as any; 952 + 953 + const res = await app.request(`/api/mod/ban/${member.did}`, { 954 + method: "DELETE", 955 + headers: { Cookie: `atbb_session=${admin.sessionToken}` }, 956 + body: JSON.stringify({ reason: "Duplicate unban" }), 957 + }); 958 + 959 + expect(res.status).toBe(200); 960 + const data = await res.json(); 961 + expect(data.alreadyActive).toBe(true); 962 + expect(mockPutRecord).not.toHaveBeenCalled(); 963 + }); 964 + }); 965 + ``` 966 + 967 + **Step 2: Run test to verify it fails** 968 + 969 + Run: `pnpm --filter @atbb/appview test mod.test.ts -t "DELETE /api/mod/ban"` 970 + 971 + Expected: FAIL - 404 Not Found 972 + 973 + **Step 3: Implement DELETE /api/mod/ban/:did endpoint** 974 + 975 + Add to mod.ts in `createModRoutes`: 976 + 977 + ```typescript 978 + /** 979 + * DELETE /api/mod/ban/:did 980 + * Unban a user (reversal action). 981 + */ 982 + app.delete( 983 + "/ban/:did", 984 + requireAuth(ctx), 985 + requirePermission(ctx, "space.atbb.permission.banUsers"), 986 + async (c) => { 987 + const user = c.get("user")!; 988 + const targetDid = c.req.param("did"); 989 + 990 + // Validate DID 991 + if (!targetDid.startsWith("did:")) { 992 + return c.json({ error: "Invalid DID format" }, 400); 993 + } 994 + 995 + // Parse request body 996 + let body: any; 997 + try { 998 + body = await c.req.json(); 999 + } catch { 1000 + return c.json({ error: "Invalid JSON in request body" }, 400); 1001 + } 1002 + 1003 + const { reason } = body; 1004 + 1005 + // Validate reason 1006 + const reasonError = validateReason(reason); 1007 + if (reasonError) { 1008 + return c.json({ error: reasonError }, 400); 1009 + } 1010 + 1011 + // Check target user exists 1012 + const [membership] = await ctx.db 1013 + .select() 1014 + .from(memberships) 1015 + .where(eq(memberships.did, targetDid)) 1016 + .limit(1); 1017 + 1018 + if (!membership) { 1019 + return c.json({ error: "User is not a member of this forum" }, 404); 1020 + } 1021 + 1022 + // Check if user is already unbanned (or never banned) 1023 + const isBanned = await checkActiveAction( 1024 + ctx, 1025 + { did: targetDid }, 1026 + "space.atbb.modAction.ban" 1027 + ); 1028 + 1029 + if (isBanned === false || isBanned === null) { 1030 + // Already unbanned or no ban history 1031 + return c.json({ 1032 + success: true, 1033 + action: "unban", 1034 + targetDid, 1035 + alreadyActive: true, 1036 + }, 200); 1037 + } 1038 + 1039 + // Get ForumAgent 1040 + if (!ctx.forumAgent) { 1041 + return c.json({ 1042 + error: "Forum agent not available. Server configuration issue.", 1043 + }, 500); 1044 + } 1045 + 1046 + const agent = ctx.forumAgent.getAgent(); 1047 + if (!agent) { 1048 + return c.json({ 1049 + error: "Forum agent not authenticated. Please try again later.", 1050 + }, 503); 1051 + } 1052 + 1053 + // Write unban modAction record 1054 + try { 1055 + const result = await agent.com.atproto.repo.putRecord({ 1056 + repo: ctx.config.forumDid, 1057 + collection: "space.atbb.modAction", 1058 + rkey: TID.nextStr(), 1059 + record: { 1060 + $type: "space.atbb.modAction", 1061 + action: "space.atbb.modAction.unban", 1062 + subject: { did: targetDid }, 1063 + reason, 1064 + createdBy: user.did, 1065 + createdAt: new Date().toISOString(), 1066 + }, 1067 + }); 1068 + 1069 + return c.json({ 1070 + success: true, 1071 + action: "unban", 1072 + targetDid, 1073 + uri: result.uri, 1074 + cid: result.cid, 1075 + alreadyActive: false, 1076 + }, 200); 1077 + } catch (error) { 1078 + console.error("Failed to write unban modAction", { 1079 + operation: "DELETE /api/mod/ban/:did", 1080 + targetDid, 1081 + error: error instanceof Error ? error.message : String(error), 1082 + }); 1083 + 1084 + if (error instanceof Error && isNetworkError(error)) { 1085 + return c.json({ 1086 + error: "Unable to reach Forum PDS. Please try again later.", 1087 + }, 503); 1088 + } 1089 + 1090 + return c.json({ 1091 + error: "Failed to record moderation action. Please contact support.", 1092 + }, 500); 1093 + } 1094 + } 1095 + ); 1096 + ``` 1097 + 1098 + **Step 4: Run test to verify it passes** 1099 + 1100 + Run: `pnpm --filter @atbb/appview test mod.test.ts -t "DELETE /api/mod/ban"` 1101 + 1102 + Expected: PASS (2 tests) 1103 + 1104 + **Step 5: Commit** 1105 + 1106 + ```bash 1107 + git add apps/appview/src/routes/mod.ts apps/appview/src/routes/__tests__/mod.test.ts 1108 + git commit -m "feat(mod): implement DELETE /api/mod/ban/:did (unban) (ATB-19) 1109 + 1110 + Unbans user by writing unban modAction record" 1111 + ``` 1112 + 1113 + --- 1114 + 1115 + ## Task 8: Implement POST /api/mod/lock and DELETE /api/mod/lock/:topicId 1116 + 1117 + **Files:** 1118 + - Modify: `apps/appview/src/routes/mod.ts` 1119 + - Modify: `apps/appview/src/routes/__tests__/mod.test.ts` 1120 + 1121 + **Note:** Lock endpoints are similar to ban but target posts and include topic validation. 1122 + 1123 + **Step 1: Write failing tests for lock endpoints** 1124 + 1125 + Add to mod.test.ts: 1126 + 1127 + ```typescript 1128 + describe("POST /api/mod/lock", () => { 1129 + it("locks topic successfully", async () => { 1130 + const mod = await testCtx.createUser("Moderator"); 1131 + const member = await testCtx.createUser("Member"); 1132 + const topic = await testCtx.createTopic(member.did, "Test topic"); 1133 + 1134 + const mockPutRecord = vi.fn().mockResolvedValue({ 1135 + uri: "at://did:plc:forum/space.atbb.modAction/lock123", 1136 + cid: "bafylock", 1137 + }); 1138 + 1139 + testCtx.ctx.forumAgent = { 1140 + isAuthenticated: () => true, 1141 + getAgent: () => ({ 1142 + com: { atproto: { repo: { putRecord: mockPutRecord } } }, 1143 + }), 1144 + } as any; 1145 + 1146 + const res = await app.request("/api/mod/lock", { 1147 + method: "POST", 1148 + headers: { Cookie: `atbb_session=${mod.sessionToken}` }, 1149 + body: JSON.stringify({ 1150 + topicId: topic.id.toString(), 1151 + reason: "Off-topic discussion", 1152 + }), 1153 + }); 1154 + 1155 + expect(res.status).toBe(200); 1156 + const data = await res.json(); 1157 + expect(data.success).toBe(true); 1158 + expect(data.action).toBe("lock"); 1159 + expect(data.topicId).toBe(topic.id.toString()); 1160 + expect(data.alreadyActive).toBe(false); 1161 + 1162 + // Verify PDS write with post subject 1163 + expect(mockPutRecord).toHaveBeenCalledWith({ 1164 + repo: testCtx.ctx.config.forumDid, 1165 + collection: "space.atbb.modAction", 1166 + rkey: expect.any(String), 1167 + record: { 1168 + $type: "space.atbb.modAction", 1169 + action: "space.atbb.modAction.lock", 1170 + subject: { 1171 + post: { 1172 + uri: expect.stringContaining("space.atbb.post"), 1173 + cid: topic.cid, 1174 + }, 1175 + }, 1176 + reason: "Off-topic discussion", 1177 + createdBy: mod.did, 1178 + createdAt: expect.any(String), 1179 + }, 1180 + }); 1181 + }); 1182 + 1183 + it("returns 400 when trying to lock a reply post", async () => { 1184 + const mod = await testCtx.createUser("Moderator"); 1185 + const member = await testCtx.createUser("Member"); 1186 + const topic = await testCtx.createTopic(member.did, "Topic"); 1187 + const reply = await testCtx.createReply(member.did, topic.id, "Reply"); 1188 + 1189 + const res = await app.request("/api/mod/lock", { 1190 + method: "POST", 1191 + headers: { Cookie: `atbb_session=${mod.sessionToken}` }, 1192 + body: JSON.stringify({ 1193 + topicId: reply.id.toString(), 1194 + reason: "Test", 1195 + }), 1196 + }); 1197 + 1198 + expect(res.status).toBe(400); 1199 + const data = await res.json(); 1200 + expect(data.error).toContain("root posts"); 1201 + }); 1202 + 1203 + it("returns 404 when topic not found", async () => { 1204 + const mod = await testCtx.createUser("Moderator"); 1205 + 1206 + const res = await app.request("/api/mod/lock", { 1207 + method: "POST", 1208 + headers: { Cookie: `atbb_session=${mod.sessionToken}` }, 1209 + body: JSON.stringify({ 1210 + topicId: "999999", 1211 + reason: "Test", 1212 + }), 1213 + }); 1214 + 1215 + expect(res.status).toBe(404); 1216 + }); 1217 + }); 1218 + 1219 + describe("DELETE /api/mod/lock/:topicId", () => { 1220 + it("unlocks topic successfully", async () => { 1221 + const mod = await testCtx.createUser("Moderator"); 1222 + const member = await testCtx.createUser("Member"); 1223 + const topic = await testCtx.createTopic(member.did, "Test topic"); 1224 + 1225 + // Insert existing lock 1226 + const postUri = `at://${member.did}/space.atbb.post/${topic.rkey}`; 1227 + await testCtx.ctx.db.insert(modActions).values({ 1228 + did: testCtx.ctx.config.forumDid, 1229 + rkey: "lock1", 1230 + cid: "bafylock", 1231 + action: "space.atbb.modAction.lock", 1232 + subjectPostUri: postUri, 1233 + reason: "Original lock", 1234 + createdBy: mod.did, 1235 + createdAt: new Date(), 1236 + indexedAt: new Date(), 1237 + }); 1238 + 1239 + const mockPutRecord = vi.fn().mockResolvedValue({ 1240 + uri: "at://did:plc:forum/space.atbb.modAction/unlock123", 1241 + cid: "bafyunlock", 1242 + }); 1243 + 1244 + testCtx.ctx.forumAgent = { 1245 + isAuthenticated: () => true, 1246 + getAgent: () => ({ 1247 + com: { atproto: { repo: { putRecord: mockPutRecord } } }, 1248 + }), 1249 + } as any; 1250 + 1251 + const res = await app.request(`/api/mod/lock/${topic.id}`, { 1252 + method: "DELETE", 1253 + headers: { Cookie: `atbb_session=${mod.sessionToken}` }, 1254 + body: JSON.stringify({ reason: "Discussion resumed" }), 1255 + }); 1256 + 1257 + expect(res.status).toBe(200); 1258 + const data = await res.json(); 1259 + expect(data.success).toBe(true); 1260 + expect(data.action).toBe("unlock"); 1261 + }); 1262 + }); 1263 + ``` 1264 + 1265 + **Step 2: Run test to verify it fails** 1266 + 1267 + Run: `pnpm --filter @atbb/appview test mod.test.ts -t "lock"` 1268 + 1269 + Expected: FAIL - 404 Not Found 1270 + 1271 + **Step 3: Implement lock endpoints** 1272 + 1273 + Add to mod.ts: 1274 + 1275 + ```typescript 1276 + /** 1277 + * POST /api/mod/lock 1278 + * Lock a topic (prevent new replies). 1279 + */ 1280 + app.post( 1281 + "/lock", 1282 + requireAuth(ctx), 1283 + requirePermission(ctx, "space.atbb.permission.lockTopics"), 1284 + async (c) => { 1285 + const user = c.get("user")!; 1286 + 1287 + // Parse request body 1288 + let body: any; 1289 + try { 1290 + body = await c.req.json(); 1291 + } catch { 1292 + return c.json({ error: "Invalid JSON in request body" }, 400); 1293 + } 1294 + 1295 + const { topicId, reason } = body; 1296 + 1297 + // Validate topicId 1298 + if (typeof topicId !== "string") { 1299 + return c.json({ error: "topicId is required and must be a string" }, 400); 1300 + } 1301 + 1302 + const topicIdBigInt = parseBigIntParam(topicId); 1303 + if (topicIdBigInt === null) { 1304 + return c.json({ error: "Invalid topic ID" }, 400); 1305 + } 1306 + 1307 + // Validate reason 1308 + const reasonError = validateReason(reason); 1309 + if (reasonError) { 1310 + return c.json({ error: reasonError }, 400); 1311 + } 1312 + 1313 + // Get topic and validate it's a root post 1314 + const [topic] = await ctx.db 1315 + .select() 1316 + .from(posts) 1317 + .where(eq(posts.id, topicIdBigInt)) 1318 + .limit(1); 1319 + 1320 + if (!topic) { 1321 + return c.json({ error: "Topic not found" }, 404); 1322 + } 1323 + 1324 + // Verify it's a root post (not a reply) 1325 + if (topic.rootPostId !== null) { 1326 + return c.json({ 1327 + error: "Can only lock topics (root posts), not replies", 1328 + }, 400); 1329 + } 1330 + 1331 + // Build post URI 1332 + const postUri = `at://${topic.did}/space.atbb.post/${topic.rkey}`; 1333 + 1334 + // Check if topic is already locked 1335 + const isLocked = await checkActiveAction( 1336 + ctx, 1337 + { postUri }, 1338 + "space.atbb.modAction.lock" 1339 + ); 1340 + 1341 + if (isLocked) { 1342 + return c.json({ 1343 + success: true, 1344 + action: "lock", 1345 + topicId: topicId, 1346 + topicUri: postUri, 1347 + alreadyActive: true, 1348 + }, 200); 1349 + } 1350 + 1351 + // Get ForumAgent 1352 + if (!ctx.forumAgent) { 1353 + return c.json({ 1354 + error: "Forum agent not available. Server configuration issue.", 1355 + }, 500); 1356 + } 1357 + 1358 + const agent = ctx.forumAgent.getAgent(); 1359 + if (!agent) { 1360 + return c.json({ 1361 + error: "Forum agent not authenticated. Please try again later.", 1362 + }, 503); 1363 + } 1364 + 1365 + // Write lock modAction record 1366 + try { 1367 + const result = await agent.com.atproto.repo.putRecord({ 1368 + repo: ctx.config.forumDid, 1369 + collection: "space.atbb.modAction", 1370 + rkey: TID.nextStr(), 1371 + record: { 1372 + $type: "space.atbb.modAction", 1373 + action: "space.atbb.modAction.lock", 1374 + subject: { 1375 + post: { 1376 + uri: postUri, 1377 + cid: topic.cid, 1378 + }, 1379 + }, 1380 + reason, 1381 + createdBy: user.did, 1382 + createdAt: new Date().toISOString(), 1383 + }, 1384 + }); 1385 + 1386 + return c.json({ 1387 + success: true, 1388 + action: "lock", 1389 + topicId: topicId, 1390 + topicUri: postUri, 1391 + uri: result.uri, 1392 + cid: result.cid, 1393 + alreadyActive: false, 1394 + }, 200); 1395 + } catch (error) { 1396 + console.error("Failed to write lock modAction", { 1397 + operation: "POST /api/mod/lock", 1398 + topicId, 1399 + error: error instanceof Error ? error.message : String(error), 1400 + }); 1401 + 1402 + if (error instanceof Error && isNetworkError(error)) { 1403 + return c.json({ 1404 + error: "Unable to reach Forum PDS. Please try again later.", 1405 + }, 503); 1406 + } 1407 + 1408 + return c.json({ 1409 + error: "Failed to record moderation action. Please contact support.", 1410 + }, 500); 1411 + } 1412 + } 1413 + ); 1414 + 1415 + /** 1416 + * DELETE /api/mod/lock/:topicId 1417 + * Unlock a topic (reversal action). 1418 + */ 1419 + app.delete( 1420 + "/lock/:topicId", 1421 + requireAuth(ctx), 1422 + requirePermission(ctx, "space.atbb.permission.lockTopics"), 1423 + async (c) => { 1424 + const user = c.get("user")!; 1425 + const topicIdParam = c.req.param("topicId"); 1426 + 1427 + const topicIdBigInt = parseBigIntParam(topicIdParam); 1428 + if (topicIdBigInt === null) { 1429 + return c.json({ error: "Invalid topic ID" }, 400); 1430 + } 1431 + 1432 + // Parse request body 1433 + let body: any; 1434 + try { 1435 + body = await c.req.json(); 1436 + } catch { 1437 + return c.json({ error: "Invalid JSON in request body" }, 400); 1438 + } 1439 + 1440 + const { reason } = body; 1441 + 1442 + // Validate reason 1443 + const reasonError = validateReason(reason); 1444 + if (reasonError) { 1445 + return c.json({ error: reasonError }, 400); 1446 + } 1447 + 1448 + // Get topic 1449 + const [topic] = await ctx.db 1450 + .select() 1451 + .from(posts) 1452 + .where(eq(posts.id, topicIdBigInt)) 1453 + .limit(1); 1454 + 1455 + if (!topic) { 1456 + return c.json({ error: "Topic not found" }, 404); 1457 + } 1458 + 1459 + if (topic.rootPostId !== null) { 1460 + return c.json({ 1461 + error: "Can only unlock topics (root posts), not replies", 1462 + }, 400); 1463 + } 1464 + 1465 + const postUri = `at://${topic.did}/space.atbb.post/${topic.rkey}`; 1466 + 1467 + // Check if topic is already unlocked 1468 + const isLocked = await checkActiveAction( 1469 + ctx, 1470 + { postUri }, 1471 + "space.atbb.modAction.lock" 1472 + ); 1473 + 1474 + if (isLocked === false || isLocked === null) { 1475 + return c.json({ 1476 + success: true, 1477 + action: "unlock", 1478 + topicId: topicIdParam, 1479 + topicUri: postUri, 1480 + alreadyActive: true, 1481 + }, 200); 1482 + } 1483 + 1484 + // Get ForumAgent 1485 + if (!ctx.forumAgent) { 1486 + return c.json({ 1487 + error: "Forum agent not available. Server configuration issue.", 1488 + }, 500); 1489 + } 1490 + 1491 + const agent = ctx.forumAgent.getAgent(); 1492 + if (!agent) { 1493 + return c.json({ 1494 + error: "Forum agent not authenticated. Please try again later.", 1495 + }, 503); 1496 + } 1497 + 1498 + // Write unlock modAction record 1499 + try { 1500 + const result = await agent.com.atproto.repo.putRecord({ 1501 + repo: ctx.config.forumDid, 1502 + collection: "space.atbb.modAction", 1503 + rkey: TID.nextStr(), 1504 + record: { 1505 + $type: "space.atbb.modAction", 1506 + action: "space.atbb.modAction.unlock", 1507 + subject: { 1508 + post: { 1509 + uri: postUri, 1510 + cid: topic.cid, 1511 + }, 1512 + }, 1513 + reason, 1514 + createdBy: user.did, 1515 + createdAt: new Date().toISOString(), 1516 + }, 1517 + }); 1518 + 1519 + return c.json({ 1520 + success: true, 1521 + action: "unlock", 1522 + topicId: topicIdParam, 1523 + topicUri: postUri, 1524 + uri: result.uri, 1525 + cid: result.cid, 1526 + alreadyActive: false, 1527 + }, 200); 1528 + } catch (error) { 1529 + console.error("Failed to write unlock modAction", { 1530 + operation: "DELETE /api/mod/lock/:topicId", 1531 + topicId: topicIdParam, 1532 + error: error instanceof Error ? error.message : String(error), 1533 + }); 1534 + 1535 + if (error instanceof Error && isNetworkError(error)) { 1536 + return c.json({ 1537 + error: "Unable to reach Forum PDS. Please try again later.", 1538 + }, 503); 1539 + } 1540 + 1541 + return c.json({ 1542 + error: "Failed to record moderation action. Please contact support.", 1543 + }, 500); 1544 + } 1545 + } 1546 + ); 1547 + ``` 1548 + 1549 + **Step 4: Add missing import** 1550 + 1551 + Add to imports at top of mod.ts: 1552 + 1553 + ```typescript 1554 + import { parseBigIntParam } from "./helpers.js"; 1555 + ``` 1556 + 1557 + **Step 5: Run test to verify it passes** 1558 + 1559 + Run: `pnpm --filter @atbb/appview test mod.test.ts -t "lock"` 1560 + 1561 + Expected: PASS (4 tests) 1562 + 1563 + **Step 6: Commit** 1564 + 1565 + ```bash 1566 + git add apps/appview/src/routes/mod.ts apps/appview/src/routes/__tests__/mod.test.ts 1567 + git commit -m "feat(mod): implement lock/unlock topic endpoints (ATB-19) 1568 + 1569 + POST /api/mod/lock and DELETE /api/mod/lock/:topicId 1570 + Validates targets are root posts only" 1571 + ``` 1572 + 1573 + --- 1574 + 1575 + ## Task 9: Implement POST /api/mod/hide and DELETE /api/mod/hide/:postId 1576 + 1577 + **Files:** 1578 + - Modify: `apps/appview/src/routes/mod.ts` 1579 + - Modify: `apps/appview/src/routes/__tests__/mod.test.ts` 1580 + 1581 + **Note:** Hide endpoints are similar to lock but work on ANY post (topics or replies). 1582 + 1583 + **Step 1: Write failing tests for hide endpoints** 1584 + 1585 + Add to mod.test.ts: 1586 + 1587 + ```typescript 1588 + describe("POST /api/mod/hide", () => { 1589 + it("hides topic post successfully", async () => { 1590 + const mod = await testCtx.createUser("Moderator"); 1591 + const member = await testCtx.createUser("Member"); 1592 + const topic = await testCtx.createTopic(member.did, "Spam topic"); 1593 + 1594 + const mockPutRecord = vi.fn().mockResolvedValue({ 1595 + uri: "at://did:plc:forum/space.atbb.modAction/hide123", 1596 + cid: "bafyhide", 1597 + }); 1598 + 1599 + testCtx.ctx.forumAgent = { 1600 + isAuthenticated: () => true, 1601 + getAgent: () => ({ 1602 + com: { atproto: { repo: { putRecord: mockPutRecord } } }, 1603 + }), 1604 + } as any; 1605 + 1606 + const res = await app.request("/api/mod/hide", { 1607 + method: "POST", 1608 + headers: { Cookie: `atbb_session=${mod.sessionToken}` }, 1609 + body: JSON.stringify({ 1610 + postId: topic.id.toString(), 1611 + reason: "Spam content", 1612 + }), 1613 + }); 1614 + 1615 + expect(res.status).toBe(200); 1616 + const data = await res.json(); 1617 + expect(data.success).toBe(true); 1618 + expect(data.action).toBe("hide"); 1619 + expect(data.postId).toBe(topic.id.toString()); 1620 + }); 1621 + 1622 + it("hides reply post successfully", async () => { 1623 + const mod = await testCtx.createUser("Moderator"); 1624 + const member = await testCtx.createUser("Member"); 1625 + const topic = await testCtx.createTopic(member.did, "Topic"); 1626 + const reply = await testCtx.createReply(member.did, topic.id, "Spam reply"); 1627 + 1628 + const mockPutRecord = vi.fn().mockResolvedValue({ 1629 + uri: "at://did:plc:forum/space.atbb.modAction/hide456", 1630 + cid: "bafyhide2", 1631 + }); 1632 + 1633 + testCtx.ctx.forumAgent = { 1634 + isAuthenticated: () => true, 1635 + getAgent: () => ({ 1636 + com: { atproto: { repo: { putRecord: mockPutRecord } } }, 1637 + }), 1638 + } as any; 1639 + 1640 + const res = await app.request("/api/mod/hide", { 1641 + method: "POST", 1642 + headers: { Cookie: `atbb_session=${mod.sessionToken}` }, 1643 + body: JSON.stringify({ 1644 + postId: reply.id.toString(), 1645 + reason: "Harassment", 1646 + }), 1647 + }); 1648 + 1649 + expect(res.status).toBe(200); 1650 + const data = await res.json(); 1651 + expect(data.success).toBe(true); 1652 + expect(data.action).toBe("hide"); 1653 + }); 1654 + }); 1655 + 1656 + describe("DELETE /api/mod/hide/:postId", () => { 1657 + it("unhides post successfully", async () => { 1658 + const mod = await testCtx.createUser("Moderator"); 1659 + const member = await testCtx.createUser("Member"); 1660 + const topic = await testCtx.createTopic(member.did, "Test"); 1661 + 1662 + const postUri = `at://${member.did}/space.atbb.post/${topic.rkey}`; 1663 + await testCtx.ctx.db.insert(modActions).values({ 1664 + did: testCtx.ctx.config.forumDid, 1665 + rkey: "hide1", 1666 + cid: "bafyhide", 1667 + action: "space.atbb.modAction.delete", 1668 + subjectPostUri: postUri, 1669 + reason: "Original hide", 1670 + createdBy: mod.did, 1671 + createdAt: new Date(), 1672 + indexedAt: new Date(), 1673 + }); 1674 + 1675 + const mockPutRecord = vi.fn().mockResolvedValue({ 1676 + uri: "at://did:plc:forum/space.atbb.modAction/unhide123", 1677 + cid: "bafyunhide", 1678 + }); 1679 + 1680 + testCtx.ctx.forumAgent = { 1681 + isAuthenticated: () => true, 1682 + getAgent: () => ({ 1683 + com: { atproto: { repo: { putRecord: mockPutRecord } } }, 1684 + }), 1685 + } as any; 1686 + 1687 + const res = await app.request(`/api/mod/hide/${topic.id}`, { 1688 + method: "DELETE", 1689 + headers: { Cookie: `atbb_session=${mod.sessionToken}` }, 1690 + body: JSON.stringify({ reason: "False positive" }), 1691 + }); 1692 + 1693 + expect(res.status).toBe(200); 1694 + const data = await res.json(); 1695 + expect(data.success).toBe(true); 1696 + expect(data.action).toBe("unhide"); 1697 + }); 1698 + }); 1699 + ``` 1700 + 1701 + **Step 2: Run test to verify it fails** 1702 + 1703 + Run: `pnpm --filter @atbb/appview test mod.test.ts -t "hide"` 1704 + 1705 + Expected: FAIL - 404 Not Found 1706 + 1707 + **Step 3: Implement hide endpoints** 1708 + 1709 + Add to mod.ts (note: hide uses "delete" action type per lexicon): 1710 + 1711 + ```typescript 1712 + /** 1713 + * POST /api/mod/hide 1714 + * Hide a post from the forum (soft-delete). 1715 + */ 1716 + app.post( 1717 + "/hide", 1718 + requireAuth(ctx), 1719 + requirePermission(ctx, "space.atbb.permission.moderatePosts"), 1720 + async (c) => { 1721 + const user = c.get("user")!; 1722 + 1723 + // Parse request body 1724 + let body: any; 1725 + try { 1726 + body = await c.req.json(); 1727 + } catch { 1728 + return c.json({ error: "Invalid JSON in request body" }, 400); 1729 + } 1730 + 1731 + const { postId, reason } = body; 1732 + 1733 + // Validate postId 1734 + if (typeof postId !== "string") { 1735 + return c.json({ error: "postId is required and must be a string" }, 400); 1736 + } 1737 + 1738 + const postIdBigInt = parseBigIntParam(postId); 1739 + if (postIdBigInt === null) { 1740 + return c.json({ error: "Invalid post ID" }, 400); 1741 + } 1742 + 1743 + // Validate reason 1744 + const reasonError = validateReason(reason); 1745 + if (reasonError) { 1746 + return c.json({ error: reasonError }, 400); 1747 + } 1748 + 1749 + // Get post (can be topic or reply) 1750 + const [post] = await ctx.db 1751 + .select() 1752 + .from(posts) 1753 + .where(eq(posts.id, postIdBigInt)) 1754 + .limit(1); 1755 + 1756 + if (!post) { 1757 + return c.json({ error: "Post not found" }, 404); 1758 + } 1759 + 1760 + const postUri = `at://${post.did}/space.atbb.post/${post.rkey}`; 1761 + 1762 + // Check if post is already hidden 1763 + const isHidden = await checkActiveAction( 1764 + ctx, 1765 + { postUri }, 1766 + "space.atbb.modAction.delete" 1767 + ); 1768 + 1769 + if (isHidden) { 1770 + return c.json({ 1771 + success: true, 1772 + action: "hide", 1773 + postId: postId, 1774 + postUri: postUri, 1775 + alreadyActive: true, 1776 + }, 200); 1777 + } 1778 + 1779 + // Get ForumAgent 1780 + if (!ctx.forumAgent) { 1781 + return c.json({ 1782 + error: "Forum agent not available. Server configuration issue.", 1783 + }, 500); 1784 + } 1785 + 1786 + const agent = ctx.forumAgent.getAgent(); 1787 + if (!agent) { 1788 + return c.json({ 1789 + error: "Forum agent not authenticated. Please try again later.", 1790 + }, 503); 1791 + } 1792 + 1793 + // Write hide modAction record (action type is "delete" per lexicon) 1794 + try { 1795 + const result = await agent.com.atproto.repo.putRecord({ 1796 + repo: ctx.config.forumDid, 1797 + collection: "space.atbb.modAction", 1798 + rkey: TID.nextStr(), 1799 + record: { 1800 + $type: "space.atbb.modAction", 1801 + action: "space.atbb.modAction.delete", 1802 + subject: { 1803 + post: { 1804 + uri: postUri, 1805 + cid: post.cid, 1806 + }, 1807 + }, 1808 + reason, 1809 + createdBy: user.did, 1810 + createdAt: new Date().toISOString(), 1811 + }, 1812 + }); 1813 + 1814 + return c.json({ 1815 + success: true, 1816 + action: "hide", 1817 + postId: postId, 1818 + postUri: postUri, 1819 + uri: result.uri, 1820 + cid: result.cid, 1821 + alreadyActive: false, 1822 + }, 200); 1823 + } catch (error) { 1824 + console.error("Failed to write hide modAction", { 1825 + operation: "POST /api/mod/hide", 1826 + postId, 1827 + error: error instanceof Error ? error.message : String(error), 1828 + }); 1829 + 1830 + if (error instanceof Error && isNetworkError(error)) { 1831 + return c.json({ 1832 + error: "Unable to reach Forum PDS. Please try again later.", 1833 + }, 503); 1834 + } 1835 + 1836 + return c.json({ 1837 + error: "Failed to record moderation action. Please contact support.", 1838 + }, 500); 1839 + } 1840 + } 1841 + ); 1842 + 1843 + /** 1844 + * DELETE /api/mod/hide/:postId 1845 + * Unhide a post (reversal action). 1846 + */ 1847 + app.delete( 1848 + "/hide/:postId", 1849 + requireAuth(ctx), 1850 + requirePermission(ctx, "space.atbb.permission.moderatePosts"), 1851 + async (c) => { 1852 + const user = c.get("user")!; 1853 + const postIdParam = c.req.param("postId"); 1854 + 1855 + const postIdBigInt = parseBigIntParam(postIdParam); 1856 + if (postIdBigInt === null) { 1857 + return c.json({ error: "Invalid post ID" }, 400); 1858 + } 1859 + 1860 + // Parse request body 1861 + let body: any; 1862 + try { 1863 + body = await c.req.json(); 1864 + } catch { 1865 + return c.json({ error: "Invalid JSON in request body" }, 400); 1866 + } 1867 + 1868 + const { reason } = body; 1869 + 1870 + // Validate reason 1871 + const reasonError = validateReason(reason); 1872 + if (reasonError) { 1873 + return c.json({ error: reasonError }, 400); 1874 + } 1875 + 1876 + // Get post 1877 + const [post] = await ctx.db 1878 + .select() 1879 + .from(posts) 1880 + .where(eq(posts.id, postIdBigInt)) 1881 + .limit(1); 1882 + 1883 + if (!post) { 1884 + return c.json({ error: "Post not found" }, 404); 1885 + } 1886 + 1887 + const postUri = `at://${post.did}/space.atbb.post/${post.rkey}`; 1888 + 1889 + // Check if post is already unhidden 1890 + const isHidden = await checkActiveAction( 1891 + ctx, 1892 + { postUri }, 1893 + "space.atbb.modAction.delete" 1894 + ); 1895 + 1896 + if (isHidden === false || isHidden === null) { 1897 + return c.json({ 1898 + success: true, 1899 + action: "unhide", 1900 + postId: postIdParam, 1901 + postUri: postUri, 1902 + alreadyActive: true, 1903 + }, 200); 1904 + } 1905 + 1906 + // Get ForumAgent 1907 + if (!ctx.forumAgent) { 1908 + return c.json({ 1909 + error: "Forum agent not available. Server configuration issue.", 1910 + }, 500); 1911 + } 1912 + 1913 + const agent = ctx.forumAgent.getAgent(); 1914 + if (!agent) { 1915 + return c.json({ 1916 + error: "Forum agent not authenticated. Please try again later.", 1917 + }, 503); 1918 + } 1919 + 1920 + // Write unhide modAction record 1921 + // Note: lexicon doesn't define "undelete", so we infer "unhide" behavior 1922 + // The read-path logic should check for most recent action 1923 + try { 1924 + const result = await agent.com.atproto.repo.putRecord({ 1925 + repo: ctx.config.forumDid, 1926 + collection: "space.atbb.modAction", 1927 + rkey: TID.nextStr(), 1928 + record: { 1929 + $type: "space.atbb.modAction", 1930 + action: "space.atbb.modAction.delete", // Note: Using same action, read-path determines state 1931 + subject: { 1932 + post: { 1933 + uri: postUri, 1934 + cid: post.cid, 1935 + }, 1936 + }, 1937 + reason, 1938 + createdBy: user.did, 1939 + createdAt: new Date().toISOString(), 1940 + }, 1941 + }); 1942 + 1943 + return c.json({ 1944 + success: true, 1945 + action: "unhide", 1946 + postId: postIdParam, 1947 + postUri: postUri, 1948 + uri: result.uri, 1949 + cid: result.cid, 1950 + alreadyActive: false, 1951 + }, 200); 1952 + } catch (error) { 1953 + console.error("Failed to write unhide modAction", { 1954 + operation: "DELETE /api/mod/hide/:postId", 1955 + postId: postIdParam, 1956 + error: error instanceof Error ? error.message : String(error), 1957 + }); 1958 + 1959 + if (error instanceof Error && isNetworkError(error)) { 1960 + return c.json({ 1961 + error: "Unable to reach Forum PDS. Please try again later.", 1962 + }, 503); 1963 + } 1964 + 1965 + return c.json({ 1966 + error: "Failed to record moderation action. Please contact support.", 1967 + }, 500); 1968 + } 1969 + } 1970 + ); 1971 + ``` 1972 + 1973 + **Step 4: Run test to verify it passes** 1974 + 1975 + Run: `pnpm --filter @atbb/appview test mod.test.ts -t "hide"` 1976 + 1977 + Expected: PASS (3 tests) 1978 + 1979 + **Step 5: Note lexicon gap for unhide action** 1980 + 1981 + The lexicon doesn't define an "unhide" or "undelete" action type. We're using "delete" for both hide and unhide, with read-path logic determining state based on alternating actions. This should be noted in implementation comments. 1982 + 1983 + **Step 6: Commit** 1984 + 1985 + ```bash 1986 + git add apps/appview/src/routes/mod.ts apps/appview/src/routes/__tests__/mod.test.ts 1987 + git commit -m "feat(mod): implement hide/unhide post endpoints (ATB-19) 1988 + 1989 + POST /api/mod/hide and DELETE /api/mod/hide/:postId 1990 + Works on both topics and replies (unlike lock)" 1991 + ``` 1992 + 1993 + --- 1994 + 1995 + ## Task 10: Run All Tests and Verify Coverage 1996 + 1997 + **Step 1: Run all mod tests** 1998 + 1999 + Run: `pnpm --filter @atbb/appview test mod.test.ts` 2000 + 2001 + Expected: PASS (~30+ tests including helpers and all endpoints) 2002 + 2003 + **Step 2: Run all appview tests** 2004 + 2005 + Run: `pnpm --filter @atbb/appview test` 2006 + 2007 + Expected: PASS (all existing tests + new mod tests) 2008 + 2009 + **Step 3: Check test count** 2010 + 2011 + Verify we have comprehensive coverage: 2012 + - Helper tests: ~7 tests 2013 + - Ban tests: ~13 tests 2014 + - Unban tests: ~2 tests (basic coverage, can expand) 2015 + - Lock tests: ~4 tests 2016 + - Unlock tests: ~1 test 2017 + - Hide tests: ~3 tests 2018 + - Unhide tests: ~1 test 2019 + 2020 + **Total: ~30 tests** (can expand to ~75-80 with full error coverage per endpoint) 2021 + 2022 + **Step 4: Commit test verification** 2023 + 2024 + ```bash 2025 + git add -A 2026 + git commit -m "test(mod): verify all moderation endpoint tests pass (ATB-19) 2027 + 2028 + ~30 tests covering happy path, validation, and basic error handling" 2029 + ``` 2030 + 2031 + --- 2032 + 2033 + ## Task 11: Update Bruno API Collection 2034 + 2035 + **Files:** 2036 + - Create: `bruno/AppView API/Moderation/Ban User.bru` 2037 + - Create: `bruno/AppView API/Moderation/Unban User.bru` 2038 + - Create: `bruno/AppView API/Moderation/Lock Topic.bru` 2039 + - Create: `bruno/AppView API/Moderation/Unlock Topic.bru` 2040 + - Create: `bruno/AppView API/Moderation/Hide Post.bru` 2041 + - Create: `bruno/AppView API/Moderation/Unhide Post.bru` 2042 + 2043 + **Step 1: Create Moderation directory** 2044 + 2045 + ```bash 2046 + mkdir -p "bruno/AppView API/Moderation" 2047 + ``` 2048 + 2049 + **Step 2: Create Ban User.bru** 2050 + 2051 + Create: `bruno/AppView API/Moderation/Ban User.bru` 2052 + 2053 + ```bru 2054 + meta { 2055 + name: Ban User 2056 + type: http 2057 + seq: 1 2058 + } 2059 + 2060 + post { 2061 + url: {{appview_url}}/api/mod/ban 2062 + } 2063 + 2064 + headers { 2065 + Content-Type: application/json 2066 + Cookie: atbb_session={{session_token}} 2067 + } 2068 + 2069 + body:json { 2070 + { 2071 + "targetDid": "did:plc:example", 2072 + "reason": "Spam and harassment" 2073 + } 2074 + } 2075 + 2076 + assert { 2077 + res.status: eq 200 2078 + res.body.success: eq true 2079 + res.body.action: eq ban 2080 + } 2081 + 2082 + docs { 2083 + Ban a user from the forum. 2084 + 2085 + **Permission required:** space.atbb.permission.banUsers (Owner, Admin) 2086 + 2087 + Request body: 2088 + - targetDid: User DID to ban (required, string, must start with "did:") 2089 + - reason: Moderator's reason (required, 1-3000 chars, non-empty) 2090 + 2091 + Returns: 2092 + { 2093 + "success": true, 2094 + "action": "ban", 2095 + "targetDid": "did:plc:example", 2096 + "uri": "at://did:plc:forum/space.atbb.modAction/3kh5...", 2097 + "cid": "bafyrei...", 2098 + "alreadyActive": false // true if user already banned 2099 + } 2100 + 2101 + Error codes: 2102 + - 400: Invalid DID format, missing/empty reason, reason too long 2103 + - 401: Not authenticated 2104 + - 403: Lacks banUsers permission 2105 + - 404: User not a member of forum 2106 + - 500: ForumAgent not available (server config issue) 2107 + - 503: PDS write failed (network error, retry) 2108 + 2109 + Idempotent: Returns 200 with alreadyActive: true if user already banned. 2110 + } 2111 + ``` 2112 + 2113 + **Step 3: Create remaining Bruno files** 2114 + 2115 + Create similar `.bru` files for: 2116 + - Unban User (DELETE `/api/mod/ban/:did`) 2117 + - Lock Topic (POST `/api/mod/lock`) 2118 + - Unlock Topic (DELETE `/api/mod/lock/:topicId`) 2119 + - Hide Post (POST `/api/mod/hide`) 2120 + - Unhide Post (DELETE `/api/mod/hide/:postId`) 2121 + 2122 + Each should follow the same pattern with: 2123 + - Correct HTTP method and endpoint 2124 + - Request body schema 2125 + - Success response format 2126 + - All error codes documented 2127 + - Assertions for 200 status 2128 + 2129 + **Step 4: Commit Bruno collection** 2130 + 2131 + ```bash 2132 + git add "bruno/AppView API/Moderation/" 2133 + git commit -m "docs(bruno): add moderation endpoint collection (ATB-19) 2134 + 2135 + Documented all 6 moderation endpoints with examples and error codes" 2136 + ``` 2137 + 2138 + --- 2139 + 2140 + ## Task 12: Update Documentation 2141 + 2142 + **Files:** 2143 + - Modify: `docs/atproto-forum-plan.md` 2144 + 2145 + **Step 1: Mark Phase 3 moderation items complete** 2146 + 2147 + Edit `docs/atproto-forum-plan.md`: 2148 + 2149 + Find Phase 3 section and update: 2150 + 2151 + ```markdown 2152 + #### Phase 3: Moderation Basics (Week 6–7) 2153 + - [x] Mod actions written as records on Forum DID's PDS **via AppView** (AppView holds Forum DID signing keys, verifies caller's role before writing) — **Complete:** ATB-19 implemented 6 endpoints (ban/lock/hide + reversals). Writes modAction records to Forum DID's PDS using ForumAgent. 2026-02-15 2154 + - [ ] Admin UI: ban user, lock topic, hide post 2155 + - [ ] AppView respects mod actions during indexing and API responses 2156 + - [ ] Banned users' new records are ignored by indexer 2157 + - [ ] Document the trust model: operators must trust their AppView instance, which is acceptable for self-hosted single-server deployments 2158 + ``` 2159 + 2160 + **Step 2: Commit documentation update** 2161 + 2162 + ```bash 2163 + git add docs/atproto-forum-plan.md 2164 + git commit -m "docs: mark ATB-19 complete in project plan 2165 + 2166 + Moderation action write-path endpoints implemented" 2167 + ``` 2168 + 2169 + --- 2170 + 2171 + ## Task 13: Update Linear Issue 2172 + 2173 + **Step 1: Update ATB-19 in Linear** 2174 + 2175 + Using Linear MCP tool or manually: 2176 + 2177 + 1. Change status to "Done" 2178 + 2. Add completion comment: 2179 + 2180 + ``` 2181 + Implementation complete (2026-02-15): 2182 + 2183 + ✅ 6 endpoints implemented: 2184 + - POST /api/mod/ban (ban user) 2185 + - DELETE /api/mod/ban/:did (unban user) 2186 + - POST /api/mod/lock (lock topic) 2187 + - DELETE /api/mod/lock/:topicId (unlock topic) 2188 + - POST /api/mod/hide (hide post) 2189 + - DELETE /api/mod/hide/:postId (unhide post) 2190 + 2191 + ✅ Permission enforcement via middleware 2192 + ✅ Idempotent API design (alreadyActive flag) 2193 + ✅ Comprehensive error handling (400/401/403/404/500/503) 2194 + ✅ ~30 tests (can expand to ~75-80 with full error coverage) 2195 + ✅ Bruno collection updated 2196 + ✅ Design doc: docs/plans/2026-02-15-moderation-endpoints-design.md 2197 + ✅ Implementation: apps/appview/src/routes/mod.ts 2198 + 2199 + Next: ATB-20 (read-path enforcement), ATB-21 (indexer enforcement) 2200 + ``` 2201 + 2202 + --- 2203 + 2204 + ## Summary 2205 + 2206 + **Files Created:** 2207 + - `apps/appview/src/routes/mod.ts` (~600 lines) 2208 + - `apps/appview/src/routes/__tests__/mod.test.ts` (~400 lines) 2209 + - `bruno/AppView API/Moderation/*.bru` (6 files) 2210 + - `docs/plans/2026-02-15-moderation-endpoints-design.md` 2211 + - `docs/plans/2026-02-15-moderation-endpoints-implementation.md` 2212 + 2213 + **Files Modified:** 2214 + - `apps/appview/src/lib/seed-roles.ts` (added mod permissions) 2215 + - `apps/appview/src/routes/index.ts` (registered mod routes) 2216 + - `docs/atproto-forum-plan.md` (marked Phase 3 item complete) 2217 + 2218 + **Tests:** ~30 comprehensive tests (expandable to ~75-80) 2219 + 2220 + **Design Decisions:** 2221 + - Additive reversal model (unban/unlock as new records) 2222 + - Idempotent API (alreadyActive flag) 2223 + - Required reason field 2224 + - Lock restricted to topics only 2225 + - Fully namespaced permissions 2226 + 2227 + **Next Steps (Out of Scope for ATB-19):** 2228 + - ATB-20: Read-path enforcement (filter banned users, locked topics, hidden posts) 2229 + - ATB-21: Indexer enforcement (ignore banned users' new posts) 2230 + - ATB-24: Admin moderation UI