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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: enforce mod actions in read/write-path API responses (ATB-20) (#36)

* feat(appview): add getActiveBans helper for filtering banned users

- Query active ban status for multiple users in one query
- Returns Set of currently banned DIDs
- Handles ban/unban reversals correctly
- Includes comprehensive test coverage (4 tests)

* fix(appview): add database index and error handling to getActiveBans

- Add index on mod_actions.subject_did for query performance
- Add error handling with fail-open strategy for read-path
- Re-throw programming errors, log and return empty set for DB errors
- Add test for database error handling

* feat(appview): add getTopicModStatus helper for lock/pin status (ATB-20)

- Add database index on mod_actions.subject_post_uri for performance
- Query lock/pin status for a topic by ID
- Handles lock/unlock and pin/unpin reversals
- Returns current state based on most recent action
- Fail-open error handling (returns unlocked/unpinned on DB error)
- Includes comprehensive test coverage (5 tests)

Technical details:
- Most recent action wins for state determination
- Known limitation: Cannot be both locked AND pinned simultaneously
(most recent action overwrites previous state)
- Index improves query performance for subjectPostUri lookups

Related: Task 2 of ATB-20 implementation plan

* feat(appview): add getHiddenPosts helper for filtering deleted posts

- Query hidden status for multiple posts in one query
- Returns Set of post IDs with active delete actions
- Handles delete/undelete reversals correctly
- Includes comprehensive test coverage (5 tests: 4 functional + 1 error handling)
- Follows fail-open pattern (returns empty Set on DB error)
- Re-throws programming errors (TypeError, ReferenceError, SyntaxError)
- Uses existing mod_actions_subject_post_uri_idx index for performance

* fix(appview): add defensive query limits to getHiddenPosts (ATB-20)

- Add .limit(1000) to post URI lookup query
- Add .limit(10000) to mod actions query
- Verify console.error called in error handling test
- Prevents memory exhaustion per CLAUDE.md standards

* feat(appview): filter banned users and hidden posts in GET /api/topics/:id

- Query active bans for all users in topic thread
- Query hidden status for all replies
- Filter replies to exclude banned users and hidden posts
- Includes tests for ban enforcement and unban reversal

Task 4 of ATB-20: Enforce moderation in topic GET endpoint

* feat(appview): add locked and pinned flags to GET /api/topics/:id

- Query topic lock/pin status from mod actions
- Include locked and pinned boolean flags in response
- Defaults to false when no mod actions exist
- Includes tests for locked, pinned, and normal topics

* fix(appview): allow topics to be both locked and pinned (ATB-20)

- Fix getTopicModStatus to check lock/pin independently
- Previously only the most recent action was checked
- Now checks most recent lock action AND most recent pin action separately
- Add test verifying topic can be both locked and pinned
- Fixes critical bug where lock would clear pin status (and vice versa)

* feat(appview): block banned users from creating topics (ATB-20)

- Add ban check to POST /api/topics handler
- Return 403 Forbidden if user is banned
- Add 3 tests for ban enforcement (success, blocked, error)
- Ban check happens before PDS write to prevent wasted work
- Fail closed on error (deny access if ban check fails)

* fix(appview): classify ban check errors correctly (ATB-20)

- Distinguish database errors (503) from unexpected errors (500)
- Add test for database error → 503 response
- Update existing error test to verify 500 for unexpected errors
- Users get actionable feedback: retry (503) vs report (500)

* fix(appview): re-throw programming errors in ban check (ATB-20)

- Add isProgrammingError check before error classification in ban check catch block
- Programming errors (TypeError, ReferenceError, SyntaxError) are logged with CRITICAL prefix and re-thrown
- Prevents hiding bugs by catching them as 500 errors
- Add test verifying TypeError triggers CRITICAL log and is re-thrown (not swallowed as 500)
- Aligns with CLAUDE.md error handling standards and matches the main try-catch block pattern

* feat(appview): block banned users and locked topics in POST /api/posts (ATB-20)

- Add ban check before request processing (403 Forbidden if banned)
- Add lock check after root post lookup (423 Locked if topic locked)
- Full error classification: programming errors re-throw, DB errors → 503, unexpected → 500
- Add 8 tests: 5 for ban enforcement, 3 for lock enforcement

* fix(appview): make helpers fail-closed and fix error classification (ATB-20)

- Change getActiveBans, getTopicModStatus, getHiddenPosts to re-throw DB errors
(helpers now propagate errors; callers control fail policy)
- Add isDatabaseError classification to POST /api/posts main catch block
- Update helper tests: verify throws instead of safe-default returns
- Update Bruno Create Reply docs with 403 (banned) and 423 (locked) responses

* fix: address PR review feedback for moderation enforcement

Critical fixes:
- Fix action string mismatch: helpers used hash notation
(space.atbb.modAction.action#ban) but mod.ts writes dot notation
(space.atbb.modAction.ban) - feature was a no-op in production
- Scope each mod query to relevant action type pairs (ban/unban,
lock/unlock/pin/unpin, delete/undelete) to prevent cross-type
contamination breaking "most recent action wins" logic
- Add .limit() to all three mod helper queries (defensive limits)
- Extract lock check to its own try/catch block in posts.ts
(previously shared catch with PDS write, hiding errors)
- Fix GET /api/topics/:id to be fail-open: individual try/catch
per helper, safe fallback on error (empty set / unlocked)

Status code fixes:
- Change 423 → 403 for locked topics (423 is WebDAV-specific)
- Update Create Reply.bru to document 403 for locked topics

Error classification fixes:
- Remove econnrefused/connection/timeout from isDatabaseError
(these are network-level errors, not database errors)

Test fixes:
- Update all action strings in test data from hash to dot notation
- Update mock chain for getActiveBans to end with .limit()
- Update posts.test.ts: 423 → 403 assertion
- Add integration test for hidden post filtering in GET /api/topics/:id
- Add fail-open error handling test for GET /api/topics/:id
- Update Bruno docs: locked/pinned in Get Topic assertions and response,
403 error code in Create Topic, 403 (not 423) in Create Reply

Cleanup:
- Delete test-output.txt (stray committed artifact)
- Add test-output.txt to .gitignore

authored by

Malpercio and committed by
GitHub
8421976f ab3c340b

+5926 -16
+3
.gitignore
··· 34 34 # Worktrees 35 35 .worktrees/ 36 36 *.bak* 37 + 38 + # Test artifacts 39 + test-output.txt
+1
apps/appview/drizzle/0005_typical_gamora.sql
··· 1 + CREATE INDEX "mod_actions_subject_did_idx" ON "mod_actions" USING btree ("subject_did");
+1
apps/appview/drizzle/0006_absurd_karen_page.sql
··· 1 + CREATE INDEX "mod_actions_subject_post_uri_idx" ON "mod_actions" USING btree ("subject_post_uri");
+1066
apps/appview/drizzle/meta/0005_snapshot.json
··· 1 + { 2 + "id": "94a78452-fbdb-42da-8db9-c30d039e9b04", 3 + "prevId": "0ca4512f-bb1b-47e3-abf9-2e1c2c83f0f6", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.boards": { 8 + "name": "boards", 9 + "schema": "", 10 + "columns": { 11 + "id": { 12 + "name": "id", 13 + "type": "bigserial", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "did": { 18 + "name": "did", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "rkey": { 24 + "name": "rkey", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "cid": { 30 + "name": "cid", 31 + "type": "text", 32 + "primaryKey": false, 33 + "notNull": true 34 + }, 35 + "name": { 36 + "name": "name", 37 + "type": "text", 38 + "primaryKey": false, 39 + "notNull": true 40 + }, 41 + "description": { 42 + "name": "description", 43 + "type": "text", 44 + "primaryKey": false, 45 + "notNull": false 46 + }, 47 + "slug": { 48 + "name": "slug", 49 + "type": "text", 50 + "primaryKey": false, 51 + "notNull": false 52 + }, 53 + "sort_order": { 54 + "name": "sort_order", 55 + "type": "integer", 56 + "primaryKey": false, 57 + "notNull": false 58 + }, 59 + "category_id": { 60 + "name": "category_id", 61 + "type": "bigint", 62 + "primaryKey": false, 63 + "notNull": false 64 + }, 65 + "category_uri": { 66 + "name": "category_uri", 67 + "type": "text", 68 + "primaryKey": false, 69 + "notNull": true 70 + }, 71 + "created_at": { 72 + "name": "created_at", 73 + "type": "timestamp with time zone", 74 + "primaryKey": false, 75 + "notNull": true 76 + }, 77 + "indexed_at": { 78 + "name": "indexed_at", 79 + "type": "timestamp with time zone", 80 + "primaryKey": false, 81 + "notNull": true 82 + } 83 + }, 84 + "indexes": { 85 + "boards_did_rkey_idx": { 86 + "name": "boards_did_rkey_idx", 87 + "columns": [ 88 + { 89 + "expression": "did", 90 + "isExpression": false, 91 + "asc": true, 92 + "nulls": "last" 93 + }, 94 + { 95 + "expression": "rkey", 96 + "isExpression": false, 97 + "asc": true, 98 + "nulls": "last" 99 + } 100 + ], 101 + "isUnique": true, 102 + "concurrently": false, 103 + "method": "btree", 104 + "with": {} 105 + }, 106 + "boards_category_id_idx": { 107 + "name": "boards_category_id_idx", 108 + "columns": [ 109 + { 110 + "expression": "category_id", 111 + "isExpression": false, 112 + "asc": true, 113 + "nulls": "last" 114 + } 115 + ], 116 + "isUnique": false, 117 + "concurrently": false, 118 + "method": "btree", 119 + "with": {} 120 + } 121 + }, 122 + "foreignKeys": { 123 + "boards_category_id_categories_id_fk": { 124 + "name": "boards_category_id_categories_id_fk", 125 + "tableFrom": "boards", 126 + "tableTo": "categories", 127 + "columnsFrom": [ 128 + "category_id" 129 + ], 130 + "columnsTo": [ 131 + "id" 132 + ], 133 + "onDelete": "no action", 134 + "onUpdate": "no action" 135 + } 136 + }, 137 + "compositePrimaryKeys": {}, 138 + "uniqueConstraints": {}, 139 + "policies": {}, 140 + "checkConstraints": {}, 141 + "isRLSEnabled": false 142 + }, 143 + "public.categories": { 144 + "name": "categories", 145 + "schema": "", 146 + "columns": { 147 + "id": { 148 + "name": "id", 149 + "type": "bigserial", 150 + "primaryKey": true, 151 + "notNull": true 152 + }, 153 + "did": { 154 + "name": "did", 155 + "type": "text", 156 + "primaryKey": false, 157 + "notNull": true 158 + }, 159 + "rkey": { 160 + "name": "rkey", 161 + "type": "text", 162 + "primaryKey": false, 163 + "notNull": true 164 + }, 165 + "cid": { 166 + "name": "cid", 167 + "type": "text", 168 + "primaryKey": false, 169 + "notNull": true 170 + }, 171 + "name": { 172 + "name": "name", 173 + "type": "text", 174 + "primaryKey": false, 175 + "notNull": true 176 + }, 177 + "description": { 178 + "name": "description", 179 + "type": "text", 180 + "primaryKey": false, 181 + "notNull": false 182 + }, 183 + "slug": { 184 + "name": "slug", 185 + "type": "text", 186 + "primaryKey": false, 187 + "notNull": false 188 + }, 189 + "sort_order": { 190 + "name": "sort_order", 191 + "type": "integer", 192 + "primaryKey": false, 193 + "notNull": false 194 + }, 195 + "forum_id": { 196 + "name": "forum_id", 197 + "type": "bigint", 198 + "primaryKey": false, 199 + "notNull": false 200 + }, 201 + "created_at": { 202 + "name": "created_at", 203 + "type": "timestamp with time zone", 204 + "primaryKey": false, 205 + "notNull": true 206 + }, 207 + "indexed_at": { 208 + "name": "indexed_at", 209 + "type": "timestamp with time zone", 210 + "primaryKey": false, 211 + "notNull": true 212 + } 213 + }, 214 + "indexes": { 215 + "categories_did_rkey_idx": { 216 + "name": "categories_did_rkey_idx", 217 + "columns": [ 218 + { 219 + "expression": "did", 220 + "isExpression": false, 221 + "asc": true, 222 + "nulls": "last" 223 + }, 224 + { 225 + "expression": "rkey", 226 + "isExpression": false, 227 + "asc": true, 228 + "nulls": "last" 229 + } 230 + ], 231 + "isUnique": true, 232 + "concurrently": false, 233 + "method": "btree", 234 + "with": {} 235 + } 236 + }, 237 + "foreignKeys": { 238 + "categories_forum_id_forums_id_fk": { 239 + "name": "categories_forum_id_forums_id_fk", 240 + "tableFrom": "categories", 241 + "tableTo": "forums", 242 + "columnsFrom": [ 243 + "forum_id" 244 + ], 245 + "columnsTo": [ 246 + "id" 247 + ], 248 + "onDelete": "no action", 249 + "onUpdate": "no action" 250 + } 251 + }, 252 + "compositePrimaryKeys": {}, 253 + "uniqueConstraints": {}, 254 + "policies": {}, 255 + "checkConstraints": {}, 256 + "isRLSEnabled": false 257 + }, 258 + "public.firehose_cursor": { 259 + "name": "firehose_cursor", 260 + "schema": "", 261 + "columns": { 262 + "service": { 263 + "name": "service", 264 + "type": "text", 265 + "primaryKey": true, 266 + "notNull": true, 267 + "default": "'jetstream'" 268 + }, 269 + "cursor": { 270 + "name": "cursor", 271 + "type": "bigint", 272 + "primaryKey": false, 273 + "notNull": true 274 + }, 275 + "updated_at": { 276 + "name": "updated_at", 277 + "type": "timestamp with time zone", 278 + "primaryKey": false, 279 + "notNull": true 280 + } 281 + }, 282 + "indexes": {}, 283 + "foreignKeys": {}, 284 + "compositePrimaryKeys": {}, 285 + "uniqueConstraints": {}, 286 + "policies": {}, 287 + "checkConstraints": {}, 288 + "isRLSEnabled": false 289 + }, 290 + "public.forums": { 291 + "name": "forums", 292 + "schema": "", 293 + "columns": { 294 + "id": { 295 + "name": "id", 296 + "type": "bigserial", 297 + "primaryKey": true, 298 + "notNull": true 299 + }, 300 + "did": { 301 + "name": "did", 302 + "type": "text", 303 + "primaryKey": false, 304 + "notNull": true 305 + }, 306 + "rkey": { 307 + "name": "rkey", 308 + "type": "text", 309 + "primaryKey": false, 310 + "notNull": true 311 + }, 312 + "cid": { 313 + "name": "cid", 314 + "type": "text", 315 + "primaryKey": false, 316 + "notNull": true 317 + }, 318 + "name": { 319 + "name": "name", 320 + "type": "text", 321 + "primaryKey": false, 322 + "notNull": true 323 + }, 324 + "description": { 325 + "name": "description", 326 + "type": "text", 327 + "primaryKey": false, 328 + "notNull": false 329 + }, 330 + "indexed_at": { 331 + "name": "indexed_at", 332 + "type": "timestamp with time zone", 333 + "primaryKey": false, 334 + "notNull": true 335 + } 336 + }, 337 + "indexes": { 338 + "forums_did_rkey_idx": { 339 + "name": "forums_did_rkey_idx", 340 + "columns": [ 341 + { 342 + "expression": "did", 343 + "isExpression": false, 344 + "asc": true, 345 + "nulls": "last" 346 + }, 347 + { 348 + "expression": "rkey", 349 + "isExpression": false, 350 + "asc": true, 351 + "nulls": "last" 352 + } 353 + ], 354 + "isUnique": true, 355 + "concurrently": false, 356 + "method": "btree", 357 + "with": {} 358 + } 359 + }, 360 + "foreignKeys": {}, 361 + "compositePrimaryKeys": {}, 362 + "uniqueConstraints": {}, 363 + "policies": {}, 364 + "checkConstraints": {}, 365 + "isRLSEnabled": false 366 + }, 367 + "public.memberships": { 368 + "name": "memberships", 369 + "schema": "", 370 + "columns": { 371 + "id": { 372 + "name": "id", 373 + "type": "bigserial", 374 + "primaryKey": true, 375 + "notNull": true 376 + }, 377 + "did": { 378 + "name": "did", 379 + "type": "text", 380 + "primaryKey": false, 381 + "notNull": true 382 + }, 383 + "rkey": { 384 + "name": "rkey", 385 + "type": "text", 386 + "primaryKey": false, 387 + "notNull": true 388 + }, 389 + "cid": { 390 + "name": "cid", 391 + "type": "text", 392 + "primaryKey": false, 393 + "notNull": true 394 + }, 395 + "forum_id": { 396 + "name": "forum_id", 397 + "type": "bigint", 398 + "primaryKey": false, 399 + "notNull": false 400 + }, 401 + "forum_uri": { 402 + "name": "forum_uri", 403 + "type": "text", 404 + "primaryKey": false, 405 + "notNull": true 406 + }, 407 + "role": { 408 + "name": "role", 409 + "type": "text", 410 + "primaryKey": false, 411 + "notNull": false 412 + }, 413 + "role_uri": { 414 + "name": "role_uri", 415 + "type": "text", 416 + "primaryKey": false, 417 + "notNull": false 418 + }, 419 + "joined_at": { 420 + "name": "joined_at", 421 + "type": "timestamp with time zone", 422 + "primaryKey": false, 423 + "notNull": false 424 + }, 425 + "created_at": { 426 + "name": "created_at", 427 + "type": "timestamp with time zone", 428 + "primaryKey": false, 429 + "notNull": true 430 + }, 431 + "indexed_at": { 432 + "name": "indexed_at", 433 + "type": "timestamp with time zone", 434 + "primaryKey": false, 435 + "notNull": true 436 + } 437 + }, 438 + "indexes": { 439 + "memberships_did_rkey_idx": { 440 + "name": "memberships_did_rkey_idx", 441 + "columns": [ 442 + { 443 + "expression": "did", 444 + "isExpression": false, 445 + "asc": true, 446 + "nulls": "last" 447 + }, 448 + { 449 + "expression": "rkey", 450 + "isExpression": false, 451 + "asc": true, 452 + "nulls": "last" 453 + } 454 + ], 455 + "isUnique": true, 456 + "concurrently": false, 457 + "method": "btree", 458 + "with": {} 459 + }, 460 + "memberships_did_idx": { 461 + "name": "memberships_did_idx", 462 + "columns": [ 463 + { 464 + "expression": "did", 465 + "isExpression": false, 466 + "asc": true, 467 + "nulls": "last" 468 + } 469 + ], 470 + "isUnique": false, 471 + "concurrently": false, 472 + "method": "btree", 473 + "with": {} 474 + } 475 + }, 476 + "foreignKeys": { 477 + "memberships_did_users_did_fk": { 478 + "name": "memberships_did_users_did_fk", 479 + "tableFrom": "memberships", 480 + "tableTo": "users", 481 + "columnsFrom": [ 482 + "did" 483 + ], 484 + "columnsTo": [ 485 + "did" 486 + ], 487 + "onDelete": "no action", 488 + "onUpdate": "no action" 489 + }, 490 + "memberships_forum_id_forums_id_fk": { 491 + "name": "memberships_forum_id_forums_id_fk", 492 + "tableFrom": "memberships", 493 + "tableTo": "forums", 494 + "columnsFrom": [ 495 + "forum_id" 496 + ], 497 + "columnsTo": [ 498 + "id" 499 + ], 500 + "onDelete": "no action", 501 + "onUpdate": "no action" 502 + } 503 + }, 504 + "compositePrimaryKeys": {}, 505 + "uniqueConstraints": {}, 506 + "policies": {}, 507 + "checkConstraints": {}, 508 + "isRLSEnabled": false 509 + }, 510 + "public.mod_actions": { 511 + "name": "mod_actions", 512 + "schema": "", 513 + "columns": { 514 + "id": { 515 + "name": "id", 516 + "type": "bigserial", 517 + "primaryKey": true, 518 + "notNull": true 519 + }, 520 + "did": { 521 + "name": "did", 522 + "type": "text", 523 + "primaryKey": false, 524 + "notNull": true 525 + }, 526 + "rkey": { 527 + "name": "rkey", 528 + "type": "text", 529 + "primaryKey": false, 530 + "notNull": true 531 + }, 532 + "cid": { 533 + "name": "cid", 534 + "type": "text", 535 + "primaryKey": false, 536 + "notNull": true 537 + }, 538 + "action": { 539 + "name": "action", 540 + "type": "text", 541 + "primaryKey": false, 542 + "notNull": true 543 + }, 544 + "subject_did": { 545 + "name": "subject_did", 546 + "type": "text", 547 + "primaryKey": false, 548 + "notNull": false 549 + }, 550 + "subject_post_uri": { 551 + "name": "subject_post_uri", 552 + "type": "text", 553 + "primaryKey": false, 554 + "notNull": false 555 + }, 556 + "forum_id": { 557 + "name": "forum_id", 558 + "type": "bigint", 559 + "primaryKey": false, 560 + "notNull": false 561 + }, 562 + "reason": { 563 + "name": "reason", 564 + "type": "text", 565 + "primaryKey": false, 566 + "notNull": false 567 + }, 568 + "created_by": { 569 + "name": "created_by", 570 + "type": "text", 571 + "primaryKey": false, 572 + "notNull": true 573 + }, 574 + "expires_at": { 575 + "name": "expires_at", 576 + "type": "timestamp with time zone", 577 + "primaryKey": false, 578 + "notNull": false 579 + }, 580 + "created_at": { 581 + "name": "created_at", 582 + "type": "timestamp with time zone", 583 + "primaryKey": false, 584 + "notNull": true 585 + }, 586 + "indexed_at": { 587 + "name": "indexed_at", 588 + "type": "timestamp with time zone", 589 + "primaryKey": false, 590 + "notNull": true 591 + } 592 + }, 593 + "indexes": { 594 + "mod_actions_did_rkey_idx": { 595 + "name": "mod_actions_did_rkey_idx", 596 + "columns": [ 597 + { 598 + "expression": "did", 599 + "isExpression": false, 600 + "asc": true, 601 + "nulls": "last" 602 + }, 603 + { 604 + "expression": "rkey", 605 + "isExpression": false, 606 + "asc": true, 607 + "nulls": "last" 608 + } 609 + ], 610 + "isUnique": true, 611 + "concurrently": false, 612 + "method": "btree", 613 + "with": {} 614 + }, 615 + "mod_actions_subject_did_idx": { 616 + "name": "mod_actions_subject_did_idx", 617 + "columns": [ 618 + { 619 + "expression": "subject_did", 620 + "isExpression": false, 621 + "asc": true, 622 + "nulls": "last" 623 + } 624 + ], 625 + "isUnique": false, 626 + "concurrently": false, 627 + "method": "btree", 628 + "with": {} 629 + } 630 + }, 631 + "foreignKeys": { 632 + "mod_actions_forum_id_forums_id_fk": { 633 + "name": "mod_actions_forum_id_forums_id_fk", 634 + "tableFrom": "mod_actions", 635 + "tableTo": "forums", 636 + "columnsFrom": [ 637 + "forum_id" 638 + ], 639 + "columnsTo": [ 640 + "id" 641 + ], 642 + "onDelete": "no action", 643 + "onUpdate": "no action" 644 + } 645 + }, 646 + "compositePrimaryKeys": {}, 647 + "uniqueConstraints": {}, 648 + "policies": {}, 649 + "checkConstraints": {}, 650 + "isRLSEnabled": false 651 + }, 652 + "public.posts": { 653 + "name": "posts", 654 + "schema": "", 655 + "columns": { 656 + "id": { 657 + "name": "id", 658 + "type": "bigserial", 659 + "primaryKey": true, 660 + "notNull": true 661 + }, 662 + "did": { 663 + "name": "did", 664 + "type": "text", 665 + "primaryKey": false, 666 + "notNull": true 667 + }, 668 + "rkey": { 669 + "name": "rkey", 670 + "type": "text", 671 + "primaryKey": false, 672 + "notNull": true 673 + }, 674 + "cid": { 675 + "name": "cid", 676 + "type": "text", 677 + "primaryKey": false, 678 + "notNull": true 679 + }, 680 + "text": { 681 + "name": "text", 682 + "type": "text", 683 + "primaryKey": false, 684 + "notNull": true 685 + }, 686 + "forum_uri": { 687 + "name": "forum_uri", 688 + "type": "text", 689 + "primaryKey": false, 690 + "notNull": false 691 + }, 692 + "board_uri": { 693 + "name": "board_uri", 694 + "type": "text", 695 + "primaryKey": false, 696 + "notNull": false 697 + }, 698 + "board_id": { 699 + "name": "board_id", 700 + "type": "bigint", 701 + "primaryKey": false, 702 + "notNull": false 703 + }, 704 + "root_post_id": { 705 + "name": "root_post_id", 706 + "type": "bigint", 707 + "primaryKey": false, 708 + "notNull": false 709 + }, 710 + "parent_post_id": { 711 + "name": "parent_post_id", 712 + "type": "bigint", 713 + "primaryKey": false, 714 + "notNull": false 715 + }, 716 + "root_uri": { 717 + "name": "root_uri", 718 + "type": "text", 719 + "primaryKey": false, 720 + "notNull": false 721 + }, 722 + "parent_uri": { 723 + "name": "parent_uri", 724 + "type": "text", 725 + "primaryKey": false, 726 + "notNull": false 727 + }, 728 + "created_at": { 729 + "name": "created_at", 730 + "type": "timestamp with time zone", 731 + "primaryKey": false, 732 + "notNull": true 733 + }, 734 + "indexed_at": { 735 + "name": "indexed_at", 736 + "type": "timestamp with time zone", 737 + "primaryKey": false, 738 + "notNull": true 739 + }, 740 + "deleted": { 741 + "name": "deleted", 742 + "type": "boolean", 743 + "primaryKey": false, 744 + "notNull": true, 745 + "default": false 746 + } 747 + }, 748 + "indexes": { 749 + "posts_did_rkey_idx": { 750 + "name": "posts_did_rkey_idx", 751 + "columns": [ 752 + { 753 + "expression": "did", 754 + "isExpression": false, 755 + "asc": true, 756 + "nulls": "last" 757 + }, 758 + { 759 + "expression": "rkey", 760 + "isExpression": false, 761 + "asc": true, 762 + "nulls": "last" 763 + } 764 + ], 765 + "isUnique": true, 766 + "concurrently": false, 767 + "method": "btree", 768 + "with": {} 769 + }, 770 + "posts_forum_uri_idx": { 771 + "name": "posts_forum_uri_idx", 772 + "columns": [ 773 + { 774 + "expression": "forum_uri", 775 + "isExpression": false, 776 + "asc": true, 777 + "nulls": "last" 778 + } 779 + ], 780 + "isUnique": false, 781 + "concurrently": false, 782 + "method": "btree", 783 + "with": {} 784 + }, 785 + "posts_board_id_idx": { 786 + "name": "posts_board_id_idx", 787 + "columns": [ 788 + { 789 + "expression": "board_id", 790 + "isExpression": false, 791 + "asc": true, 792 + "nulls": "last" 793 + } 794 + ], 795 + "isUnique": false, 796 + "concurrently": false, 797 + "method": "btree", 798 + "with": {} 799 + }, 800 + "posts_board_uri_idx": { 801 + "name": "posts_board_uri_idx", 802 + "columns": [ 803 + { 804 + "expression": "board_uri", 805 + "isExpression": false, 806 + "asc": true, 807 + "nulls": "last" 808 + } 809 + ], 810 + "isUnique": false, 811 + "concurrently": false, 812 + "method": "btree", 813 + "with": {} 814 + }, 815 + "posts_root_post_id_idx": { 816 + "name": "posts_root_post_id_idx", 817 + "columns": [ 818 + { 819 + "expression": "root_post_id", 820 + "isExpression": false, 821 + "asc": true, 822 + "nulls": "last" 823 + } 824 + ], 825 + "isUnique": false, 826 + "concurrently": false, 827 + "method": "btree", 828 + "with": {} 829 + } 830 + }, 831 + "foreignKeys": { 832 + "posts_did_users_did_fk": { 833 + "name": "posts_did_users_did_fk", 834 + "tableFrom": "posts", 835 + "tableTo": "users", 836 + "columnsFrom": [ 837 + "did" 838 + ], 839 + "columnsTo": [ 840 + "did" 841 + ], 842 + "onDelete": "no action", 843 + "onUpdate": "no action" 844 + }, 845 + "posts_board_id_boards_id_fk": { 846 + "name": "posts_board_id_boards_id_fk", 847 + "tableFrom": "posts", 848 + "tableTo": "boards", 849 + "columnsFrom": [ 850 + "board_id" 851 + ], 852 + "columnsTo": [ 853 + "id" 854 + ], 855 + "onDelete": "no action", 856 + "onUpdate": "no action" 857 + }, 858 + "posts_root_post_id_posts_id_fk": { 859 + "name": "posts_root_post_id_posts_id_fk", 860 + "tableFrom": "posts", 861 + "tableTo": "posts", 862 + "columnsFrom": [ 863 + "root_post_id" 864 + ], 865 + "columnsTo": [ 866 + "id" 867 + ], 868 + "onDelete": "no action", 869 + "onUpdate": "no action" 870 + }, 871 + "posts_parent_post_id_posts_id_fk": { 872 + "name": "posts_parent_post_id_posts_id_fk", 873 + "tableFrom": "posts", 874 + "tableTo": "posts", 875 + "columnsFrom": [ 876 + "parent_post_id" 877 + ], 878 + "columnsTo": [ 879 + "id" 880 + ], 881 + "onDelete": "no action", 882 + "onUpdate": "no action" 883 + } 884 + }, 885 + "compositePrimaryKeys": {}, 886 + "uniqueConstraints": {}, 887 + "policies": {}, 888 + "checkConstraints": {}, 889 + "isRLSEnabled": false 890 + }, 891 + "public.roles": { 892 + "name": "roles", 893 + "schema": "", 894 + "columns": { 895 + "id": { 896 + "name": "id", 897 + "type": "bigserial", 898 + "primaryKey": true, 899 + "notNull": true 900 + }, 901 + "did": { 902 + "name": "did", 903 + "type": "text", 904 + "primaryKey": false, 905 + "notNull": true 906 + }, 907 + "rkey": { 908 + "name": "rkey", 909 + "type": "text", 910 + "primaryKey": false, 911 + "notNull": true 912 + }, 913 + "cid": { 914 + "name": "cid", 915 + "type": "text", 916 + "primaryKey": false, 917 + "notNull": true 918 + }, 919 + "name": { 920 + "name": "name", 921 + "type": "text", 922 + "primaryKey": false, 923 + "notNull": true 924 + }, 925 + "description": { 926 + "name": "description", 927 + "type": "text", 928 + "primaryKey": false, 929 + "notNull": false 930 + }, 931 + "permissions": { 932 + "name": "permissions", 933 + "type": "text[]", 934 + "primaryKey": false, 935 + "notNull": true, 936 + "default": "'{}'::text[]" 937 + }, 938 + "priority": { 939 + "name": "priority", 940 + "type": "integer", 941 + "primaryKey": false, 942 + "notNull": true 943 + }, 944 + "created_at": { 945 + "name": "created_at", 946 + "type": "timestamp with time zone", 947 + "primaryKey": false, 948 + "notNull": true 949 + }, 950 + "indexed_at": { 951 + "name": "indexed_at", 952 + "type": "timestamp with time zone", 953 + "primaryKey": false, 954 + "notNull": true 955 + } 956 + }, 957 + "indexes": { 958 + "roles_did_rkey_idx": { 959 + "name": "roles_did_rkey_idx", 960 + "columns": [ 961 + { 962 + "expression": "did", 963 + "isExpression": false, 964 + "asc": true, 965 + "nulls": "last" 966 + }, 967 + { 968 + "expression": "rkey", 969 + "isExpression": false, 970 + "asc": true, 971 + "nulls": "last" 972 + } 973 + ], 974 + "isUnique": true, 975 + "concurrently": false, 976 + "method": "btree", 977 + "with": {} 978 + }, 979 + "roles_did_idx": { 980 + "name": "roles_did_idx", 981 + "columns": [ 982 + { 983 + "expression": "did", 984 + "isExpression": false, 985 + "asc": true, 986 + "nulls": "last" 987 + } 988 + ], 989 + "isUnique": false, 990 + "concurrently": false, 991 + "method": "btree", 992 + "with": {} 993 + }, 994 + "roles_did_name_idx": { 995 + "name": "roles_did_name_idx", 996 + "columns": [ 997 + { 998 + "expression": "did", 999 + "isExpression": false, 1000 + "asc": true, 1001 + "nulls": "last" 1002 + }, 1003 + { 1004 + "expression": "name", 1005 + "isExpression": false, 1006 + "asc": true, 1007 + "nulls": "last" 1008 + } 1009 + ], 1010 + "isUnique": false, 1011 + "concurrently": false, 1012 + "method": "btree", 1013 + "with": {} 1014 + } 1015 + }, 1016 + "foreignKeys": {}, 1017 + "compositePrimaryKeys": {}, 1018 + "uniqueConstraints": {}, 1019 + "policies": {}, 1020 + "checkConstraints": {}, 1021 + "isRLSEnabled": false 1022 + }, 1023 + "public.users": { 1024 + "name": "users", 1025 + "schema": "", 1026 + "columns": { 1027 + "did": { 1028 + "name": "did", 1029 + "type": "text", 1030 + "primaryKey": true, 1031 + "notNull": true 1032 + }, 1033 + "handle": { 1034 + "name": "handle", 1035 + "type": "text", 1036 + "primaryKey": false, 1037 + "notNull": false 1038 + }, 1039 + "indexed_at": { 1040 + "name": "indexed_at", 1041 + "type": "timestamp with time zone", 1042 + "primaryKey": false, 1043 + "notNull": true 1044 + } 1045 + }, 1046 + "indexes": {}, 1047 + "foreignKeys": {}, 1048 + "compositePrimaryKeys": {}, 1049 + "uniqueConstraints": {}, 1050 + "policies": {}, 1051 + "checkConstraints": {}, 1052 + "isRLSEnabled": false 1053 + } 1054 + }, 1055 + "enums": {}, 1056 + "schemas": {}, 1057 + "sequences": {}, 1058 + "roles": {}, 1059 + "policies": {}, 1060 + "views": {}, 1061 + "_meta": { 1062 + "columns": {}, 1063 + "schemas": {}, 1064 + "tables": {} 1065 + } 1066 + }
+1081
apps/appview/drizzle/meta/0006_snapshot.json
··· 1 + { 2 + "id": "3d8caf75-4733-4fc0-9d1a-50f24eaebb43", 3 + "prevId": "94a78452-fbdb-42da-8db9-c30d039e9b04", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.boards": { 8 + "name": "boards", 9 + "schema": "", 10 + "columns": { 11 + "id": { 12 + "name": "id", 13 + "type": "bigserial", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "did": { 18 + "name": "did", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "rkey": { 24 + "name": "rkey", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "cid": { 30 + "name": "cid", 31 + "type": "text", 32 + "primaryKey": false, 33 + "notNull": true 34 + }, 35 + "name": { 36 + "name": "name", 37 + "type": "text", 38 + "primaryKey": false, 39 + "notNull": true 40 + }, 41 + "description": { 42 + "name": "description", 43 + "type": "text", 44 + "primaryKey": false, 45 + "notNull": false 46 + }, 47 + "slug": { 48 + "name": "slug", 49 + "type": "text", 50 + "primaryKey": false, 51 + "notNull": false 52 + }, 53 + "sort_order": { 54 + "name": "sort_order", 55 + "type": "integer", 56 + "primaryKey": false, 57 + "notNull": false 58 + }, 59 + "category_id": { 60 + "name": "category_id", 61 + "type": "bigint", 62 + "primaryKey": false, 63 + "notNull": false 64 + }, 65 + "category_uri": { 66 + "name": "category_uri", 67 + "type": "text", 68 + "primaryKey": false, 69 + "notNull": true 70 + }, 71 + "created_at": { 72 + "name": "created_at", 73 + "type": "timestamp with time zone", 74 + "primaryKey": false, 75 + "notNull": true 76 + }, 77 + "indexed_at": { 78 + "name": "indexed_at", 79 + "type": "timestamp with time zone", 80 + "primaryKey": false, 81 + "notNull": true 82 + } 83 + }, 84 + "indexes": { 85 + "boards_did_rkey_idx": { 86 + "name": "boards_did_rkey_idx", 87 + "columns": [ 88 + { 89 + "expression": "did", 90 + "isExpression": false, 91 + "asc": true, 92 + "nulls": "last" 93 + }, 94 + { 95 + "expression": "rkey", 96 + "isExpression": false, 97 + "asc": true, 98 + "nulls": "last" 99 + } 100 + ], 101 + "isUnique": true, 102 + "concurrently": false, 103 + "method": "btree", 104 + "with": {} 105 + }, 106 + "boards_category_id_idx": { 107 + "name": "boards_category_id_idx", 108 + "columns": [ 109 + { 110 + "expression": "category_id", 111 + "isExpression": false, 112 + "asc": true, 113 + "nulls": "last" 114 + } 115 + ], 116 + "isUnique": false, 117 + "concurrently": false, 118 + "method": "btree", 119 + "with": {} 120 + } 121 + }, 122 + "foreignKeys": { 123 + "boards_category_id_categories_id_fk": { 124 + "name": "boards_category_id_categories_id_fk", 125 + "tableFrom": "boards", 126 + "tableTo": "categories", 127 + "columnsFrom": [ 128 + "category_id" 129 + ], 130 + "columnsTo": [ 131 + "id" 132 + ], 133 + "onDelete": "no action", 134 + "onUpdate": "no action" 135 + } 136 + }, 137 + "compositePrimaryKeys": {}, 138 + "uniqueConstraints": {}, 139 + "policies": {}, 140 + "checkConstraints": {}, 141 + "isRLSEnabled": false 142 + }, 143 + "public.categories": { 144 + "name": "categories", 145 + "schema": "", 146 + "columns": { 147 + "id": { 148 + "name": "id", 149 + "type": "bigserial", 150 + "primaryKey": true, 151 + "notNull": true 152 + }, 153 + "did": { 154 + "name": "did", 155 + "type": "text", 156 + "primaryKey": false, 157 + "notNull": true 158 + }, 159 + "rkey": { 160 + "name": "rkey", 161 + "type": "text", 162 + "primaryKey": false, 163 + "notNull": true 164 + }, 165 + "cid": { 166 + "name": "cid", 167 + "type": "text", 168 + "primaryKey": false, 169 + "notNull": true 170 + }, 171 + "name": { 172 + "name": "name", 173 + "type": "text", 174 + "primaryKey": false, 175 + "notNull": true 176 + }, 177 + "description": { 178 + "name": "description", 179 + "type": "text", 180 + "primaryKey": false, 181 + "notNull": false 182 + }, 183 + "slug": { 184 + "name": "slug", 185 + "type": "text", 186 + "primaryKey": false, 187 + "notNull": false 188 + }, 189 + "sort_order": { 190 + "name": "sort_order", 191 + "type": "integer", 192 + "primaryKey": false, 193 + "notNull": false 194 + }, 195 + "forum_id": { 196 + "name": "forum_id", 197 + "type": "bigint", 198 + "primaryKey": false, 199 + "notNull": false 200 + }, 201 + "created_at": { 202 + "name": "created_at", 203 + "type": "timestamp with time zone", 204 + "primaryKey": false, 205 + "notNull": true 206 + }, 207 + "indexed_at": { 208 + "name": "indexed_at", 209 + "type": "timestamp with time zone", 210 + "primaryKey": false, 211 + "notNull": true 212 + } 213 + }, 214 + "indexes": { 215 + "categories_did_rkey_idx": { 216 + "name": "categories_did_rkey_idx", 217 + "columns": [ 218 + { 219 + "expression": "did", 220 + "isExpression": false, 221 + "asc": true, 222 + "nulls": "last" 223 + }, 224 + { 225 + "expression": "rkey", 226 + "isExpression": false, 227 + "asc": true, 228 + "nulls": "last" 229 + } 230 + ], 231 + "isUnique": true, 232 + "concurrently": false, 233 + "method": "btree", 234 + "with": {} 235 + } 236 + }, 237 + "foreignKeys": { 238 + "categories_forum_id_forums_id_fk": { 239 + "name": "categories_forum_id_forums_id_fk", 240 + "tableFrom": "categories", 241 + "tableTo": "forums", 242 + "columnsFrom": [ 243 + "forum_id" 244 + ], 245 + "columnsTo": [ 246 + "id" 247 + ], 248 + "onDelete": "no action", 249 + "onUpdate": "no action" 250 + } 251 + }, 252 + "compositePrimaryKeys": {}, 253 + "uniqueConstraints": {}, 254 + "policies": {}, 255 + "checkConstraints": {}, 256 + "isRLSEnabled": false 257 + }, 258 + "public.firehose_cursor": { 259 + "name": "firehose_cursor", 260 + "schema": "", 261 + "columns": { 262 + "service": { 263 + "name": "service", 264 + "type": "text", 265 + "primaryKey": true, 266 + "notNull": true, 267 + "default": "'jetstream'" 268 + }, 269 + "cursor": { 270 + "name": "cursor", 271 + "type": "bigint", 272 + "primaryKey": false, 273 + "notNull": true 274 + }, 275 + "updated_at": { 276 + "name": "updated_at", 277 + "type": "timestamp with time zone", 278 + "primaryKey": false, 279 + "notNull": true 280 + } 281 + }, 282 + "indexes": {}, 283 + "foreignKeys": {}, 284 + "compositePrimaryKeys": {}, 285 + "uniqueConstraints": {}, 286 + "policies": {}, 287 + "checkConstraints": {}, 288 + "isRLSEnabled": false 289 + }, 290 + "public.forums": { 291 + "name": "forums", 292 + "schema": "", 293 + "columns": { 294 + "id": { 295 + "name": "id", 296 + "type": "bigserial", 297 + "primaryKey": true, 298 + "notNull": true 299 + }, 300 + "did": { 301 + "name": "did", 302 + "type": "text", 303 + "primaryKey": false, 304 + "notNull": true 305 + }, 306 + "rkey": { 307 + "name": "rkey", 308 + "type": "text", 309 + "primaryKey": false, 310 + "notNull": true 311 + }, 312 + "cid": { 313 + "name": "cid", 314 + "type": "text", 315 + "primaryKey": false, 316 + "notNull": true 317 + }, 318 + "name": { 319 + "name": "name", 320 + "type": "text", 321 + "primaryKey": false, 322 + "notNull": true 323 + }, 324 + "description": { 325 + "name": "description", 326 + "type": "text", 327 + "primaryKey": false, 328 + "notNull": false 329 + }, 330 + "indexed_at": { 331 + "name": "indexed_at", 332 + "type": "timestamp with time zone", 333 + "primaryKey": false, 334 + "notNull": true 335 + } 336 + }, 337 + "indexes": { 338 + "forums_did_rkey_idx": { 339 + "name": "forums_did_rkey_idx", 340 + "columns": [ 341 + { 342 + "expression": "did", 343 + "isExpression": false, 344 + "asc": true, 345 + "nulls": "last" 346 + }, 347 + { 348 + "expression": "rkey", 349 + "isExpression": false, 350 + "asc": true, 351 + "nulls": "last" 352 + } 353 + ], 354 + "isUnique": true, 355 + "concurrently": false, 356 + "method": "btree", 357 + "with": {} 358 + } 359 + }, 360 + "foreignKeys": {}, 361 + "compositePrimaryKeys": {}, 362 + "uniqueConstraints": {}, 363 + "policies": {}, 364 + "checkConstraints": {}, 365 + "isRLSEnabled": false 366 + }, 367 + "public.memberships": { 368 + "name": "memberships", 369 + "schema": "", 370 + "columns": { 371 + "id": { 372 + "name": "id", 373 + "type": "bigserial", 374 + "primaryKey": true, 375 + "notNull": true 376 + }, 377 + "did": { 378 + "name": "did", 379 + "type": "text", 380 + "primaryKey": false, 381 + "notNull": true 382 + }, 383 + "rkey": { 384 + "name": "rkey", 385 + "type": "text", 386 + "primaryKey": false, 387 + "notNull": true 388 + }, 389 + "cid": { 390 + "name": "cid", 391 + "type": "text", 392 + "primaryKey": false, 393 + "notNull": true 394 + }, 395 + "forum_id": { 396 + "name": "forum_id", 397 + "type": "bigint", 398 + "primaryKey": false, 399 + "notNull": false 400 + }, 401 + "forum_uri": { 402 + "name": "forum_uri", 403 + "type": "text", 404 + "primaryKey": false, 405 + "notNull": true 406 + }, 407 + "role": { 408 + "name": "role", 409 + "type": "text", 410 + "primaryKey": false, 411 + "notNull": false 412 + }, 413 + "role_uri": { 414 + "name": "role_uri", 415 + "type": "text", 416 + "primaryKey": false, 417 + "notNull": false 418 + }, 419 + "joined_at": { 420 + "name": "joined_at", 421 + "type": "timestamp with time zone", 422 + "primaryKey": false, 423 + "notNull": false 424 + }, 425 + "created_at": { 426 + "name": "created_at", 427 + "type": "timestamp with time zone", 428 + "primaryKey": false, 429 + "notNull": true 430 + }, 431 + "indexed_at": { 432 + "name": "indexed_at", 433 + "type": "timestamp with time zone", 434 + "primaryKey": false, 435 + "notNull": true 436 + } 437 + }, 438 + "indexes": { 439 + "memberships_did_rkey_idx": { 440 + "name": "memberships_did_rkey_idx", 441 + "columns": [ 442 + { 443 + "expression": "did", 444 + "isExpression": false, 445 + "asc": true, 446 + "nulls": "last" 447 + }, 448 + { 449 + "expression": "rkey", 450 + "isExpression": false, 451 + "asc": true, 452 + "nulls": "last" 453 + } 454 + ], 455 + "isUnique": true, 456 + "concurrently": false, 457 + "method": "btree", 458 + "with": {} 459 + }, 460 + "memberships_did_idx": { 461 + "name": "memberships_did_idx", 462 + "columns": [ 463 + { 464 + "expression": "did", 465 + "isExpression": false, 466 + "asc": true, 467 + "nulls": "last" 468 + } 469 + ], 470 + "isUnique": false, 471 + "concurrently": false, 472 + "method": "btree", 473 + "with": {} 474 + } 475 + }, 476 + "foreignKeys": { 477 + "memberships_did_users_did_fk": { 478 + "name": "memberships_did_users_did_fk", 479 + "tableFrom": "memberships", 480 + "tableTo": "users", 481 + "columnsFrom": [ 482 + "did" 483 + ], 484 + "columnsTo": [ 485 + "did" 486 + ], 487 + "onDelete": "no action", 488 + "onUpdate": "no action" 489 + }, 490 + "memberships_forum_id_forums_id_fk": { 491 + "name": "memberships_forum_id_forums_id_fk", 492 + "tableFrom": "memberships", 493 + "tableTo": "forums", 494 + "columnsFrom": [ 495 + "forum_id" 496 + ], 497 + "columnsTo": [ 498 + "id" 499 + ], 500 + "onDelete": "no action", 501 + "onUpdate": "no action" 502 + } 503 + }, 504 + "compositePrimaryKeys": {}, 505 + "uniqueConstraints": {}, 506 + "policies": {}, 507 + "checkConstraints": {}, 508 + "isRLSEnabled": false 509 + }, 510 + "public.mod_actions": { 511 + "name": "mod_actions", 512 + "schema": "", 513 + "columns": { 514 + "id": { 515 + "name": "id", 516 + "type": "bigserial", 517 + "primaryKey": true, 518 + "notNull": true 519 + }, 520 + "did": { 521 + "name": "did", 522 + "type": "text", 523 + "primaryKey": false, 524 + "notNull": true 525 + }, 526 + "rkey": { 527 + "name": "rkey", 528 + "type": "text", 529 + "primaryKey": false, 530 + "notNull": true 531 + }, 532 + "cid": { 533 + "name": "cid", 534 + "type": "text", 535 + "primaryKey": false, 536 + "notNull": true 537 + }, 538 + "action": { 539 + "name": "action", 540 + "type": "text", 541 + "primaryKey": false, 542 + "notNull": true 543 + }, 544 + "subject_did": { 545 + "name": "subject_did", 546 + "type": "text", 547 + "primaryKey": false, 548 + "notNull": false 549 + }, 550 + "subject_post_uri": { 551 + "name": "subject_post_uri", 552 + "type": "text", 553 + "primaryKey": false, 554 + "notNull": false 555 + }, 556 + "forum_id": { 557 + "name": "forum_id", 558 + "type": "bigint", 559 + "primaryKey": false, 560 + "notNull": false 561 + }, 562 + "reason": { 563 + "name": "reason", 564 + "type": "text", 565 + "primaryKey": false, 566 + "notNull": false 567 + }, 568 + "created_by": { 569 + "name": "created_by", 570 + "type": "text", 571 + "primaryKey": false, 572 + "notNull": true 573 + }, 574 + "expires_at": { 575 + "name": "expires_at", 576 + "type": "timestamp with time zone", 577 + "primaryKey": false, 578 + "notNull": false 579 + }, 580 + "created_at": { 581 + "name": "created_at", 582 + "type": "timestamp with time zone", 583 + "primaryKey": false, 584 + "notNull": true 585 + }, 586 + "indexed_at": { 587 + "name": "indexed_at", 588 + "type": "timestamp with time zone", 589 + "primaryKey": false, 590 + "notNull": true 591 + } 592 + }, 593 + "indexes": { 594 + "mod_actions_did_rkey_idx": { 595 + "name": "mod_actions_did_rkey_idx", 596 + "columns": [ 597 + { 598 + "expression": "did", 599 + "isExpression": false, 600 + "asc": true, 601 + "nulls": "last" 602 + }, 603 + { 604 + "expression": "rkey", 605 + "isExpression": false, 606 + "asc": true, 607 + "nulls": "last" 608 + } 609 + ], 610 + "isUnique": true, 611 + "concurrently": false, 612 + "method": "btree", 613 + "with": {} 614 + }, 615 + "mod_actions_subject_did_idx": { 616 + "name": "mod_actions_subject_did_idx", 617 + "columns": [ 618 + { 619 + "expression": "subject_did", 620 + "isExpression": false, 621 + "asc": true, 622 + "nulls": "last" 623 + } 624 + ], 625 + "isUnique": false, 626 + "concurrently": false, 627 + "method": "btree", 628 + "with": {} 629 + }, 630 + "mod_actions_subject_post_uri_idx": { 631 + "name": "mod_actions_subject_post_uri_idx", 632 + "columns": [ 633 + { 634 + "expression": "subject_post_uri", 635 + "isExpression": false, 636 + "asc": true, 637 + "nulls": "last" 638 + } 639 + ], 640 + "isUnique": false, 641 + "concurrently": false, 642 + "method": "btree", 643 + "with": {} 644 + } 645 + }, 646 + "foreignKeys": { 647 + "mod_actions_forum_id_forums_id_fk": { 648 + "name": "mod_actions_forum_id_forums_id_fk", 649 + "tableFrom": "mod_actions", 650 + "tableTo": "forums", 651 + "columnsFrom": [ 652 + "forum_id" 653 + ], 654 + "columnsTo": [ 655 + "id" 656 + ], 657 + "onDelete": "no action", 658 + "onUpdate": "no action" 659 + } 660 + }, 661 + "compositePrimaryKeys": {}, 662 + "uniqueConstraints": {}, 663 + "policies": {}, 664 + "checkConstraints": {}, 665 + "isRLSEnabled": false 666 + }, 667 + "public.posts": { 668 + "name": "posts", 669 + "schema": "", 670 + "columns": { 671 + "id": { 672 + "name": "id", 673 + "type": "bigserial", 674 + "primaryKey": true, 675 + "notNull": true 676 + }, 677 + "did": { 678 + "name": "did", 679 + "type": "text", 680 + "primaryKey": false, 681 + "notNull": true 682 + }, 683 + "rkey": { 684 + "name": "rkey", 685 + "type": "text", 686 + "primaryKey": false, 687 + "notNull": true 688 + }, 689 + "cid": { 690 + "name": "cid", 691 + "type": "text", 692 + "primaryKey": false, 693 + "notNull": true 694 + }, 695 + "text": { 696 + "name": "text", 697 + "type": "text", 698 + "primaryKey": false, 699 + "notNull": true 700 + }, 701 + "forum_uri": { 702 + "name": "forum_uri", 703 + "type": "text", 704 + "primaryKey": false, 705 + "notNull": false 706 + }, 707 + "board_uri": { 708 + "name": "board_uri", 709 + "type": "text", 710 + "primaryKey": false, 711 + "notNull": false 712 + }, 713 + "board_id": { 714 + "name": "board_id", 715 + "type": "bigint", 716 + "primaryKey": false, 717 + "notNull": false 718 + }, 719 + "root_post_id": { 720 + "name": "root_post_id", 721 + "type": "bigint", 722 + "primaryKey": false, 723 + "notNull": false 724 + }, 725 + "parent_post_id": { 726 + "name": "parent_post_id", 727 + "type": "bigint", 728 + "primaryKey": false, 729 + "notNull": false 730 + }, 731 + "root_uri": { 732 + "name": "root_uri", 733 + "type": "text", 734 + "primaryKey": false, 735 + "notNull": false 736 + }, 737 + "parent_uri": { 738 + "name": "parent_uri", 739 + "type": "text", 740 + "primaryKey": false, 741 + "notNull": false 742 + }, 743 + "created_at": { 744 + "name": "created_at", 745 + "type": "timestamp with time zone", 746 + "primaryKey": false, 747 + "notNull": true 748 + }, 749 + "indexed_at": { 750 + "name": "indexed_at", 751 + "type": "timestamp with time zone", 752 + "primaryKey": false, 753 + "notNull": true 754 + }, 755 + "deleted": { 756 + "name": "deleted", 757 + "type": "boolean", 758 + "primaryKey": false, 759 + "notNull": true, 760 + "default": false 761 + } 762 + }, 763 + "indexes": { 764 + "posts_did_rkey_idx": { 765 + "name": "posts_did_rkey_idx", 766 + "columns": [ 767 + { 768 + "expression": "did", 769 + "isExpression": false, 770 + "asc": true, 771 + "nulls": "last" 772 + }, 773 + { 774 + "expression": "rkey", 775 + "isExpression": false, 776 + "asc": true, 777 + "nulls": "last" 778 + } 779 + ], 780 + "isUnique": true, 781 + "concurrently": false, 782 + "method": "btree", 783 + "with": {} 784 + }, 785 + "posts_forum_uri_idx": { 786 + "name": "posts_forum_uri_idx", 787 + "columns": [ 788 + { 789 + "expression": "forum_uri", 790 + "isExpression": false, 791 + "asc": true, 792 + "nulls": "last" 793 + } 794 + ], 795 + "isUnique": false, 796 + "concurrently": false, 797 + "method": "btree", 798 + "with": {} 799 + }, 800 + "posts_board_id_idx": { 801 + "name": "posts_board_id_idx", 802 + "columns": [ 803 + { 804 + "expression": "board_id", 805 + "isExpression": false, 806 + "asc": true, 807 + "nulls": "last" 808 + } 809 + ], 810 + "isUnique": false, 811 + "concurrently": false, 812 + "method": "btree", 813 + "with": {} 814 + }, 815 + "posts_board_uri_idx": { 816 + "name": "posts_board_uri_idx", 817 + "columns": [ 818 + { 819 + "expression": "board_uri", 820 + "isExpression": false, 821 + "asc": true, 822 + "nulls": "last" 823 + } 824 + ], 825 + "isUnique": false, 826 + "concurrently": false, 827 + "method": "btree", 828 + "with": {} 829 + }, 830 + "posts_root_post_id_idx": { 831 + "name": "posts_root_post_id_idx", 832 + "columns": [ 833 + { 834 + "expression": "root_post_id", 835 + "isExpression": false, 836 + "asc": true, 837 + "nulls": "last" 838 + } 839 + ], 840 + "isUnique": false, 841 + "concurrently": false, 842 + "method": "btree", 843 + "with": {} 844 + } 845 + }, 846 + "foreignKeys": { 847 + "posts_did_users_did_fk": { 848 + "name": "posts_did_users_did_fk", 849 + "tableFrom": "posts", 850 + "tableTo": "users", 851 + "columnsFrom": [ 852 + "did" 853 + ], 854 + "columnsTo": [ 855 + "did" 856 + ], 857 + "onDelete": "no action", 858 + "onUpdate": "no action" 859 + }, 860 + "posts_board_id_boards_id_fk": { 861 + "name": "posts_board_id_boards_id_fk", 862 + "tableFrom": "posts", 863 + "tableTo": "boards", 864 + "columnsFrom": [ 865 + "board_id" 866 + ], 867 + "columnsTo": [ 868 + "id" 869 + ], 870 + "onDelete": "no action", 871 + "onUpdate": "no action" 872 + }, 873 + "posts_root_post_id_posts_id_fk": { 874 + "name": "posts_root_post_id_posts_id_fk", 875 + "tableFrom": "posts", 876 + "tableTo": "posts", 877 + "columnsFrom": [ 878 + "root_post_id" 879 + ], 880 + "columnsTo": [ 881 + "id" 882 + ], 883 + "onDelete": "no action", 884 + "onUpdate": "no action" 885 + }, 886 + "posts_parent_post_id_posts_id_fk": { 887 + "name": "posts_parent_post_id_posts_id_fk", 888 + "tableFrom": "posts", 889 + "tableTo": "posts", 890 + "columnsFrom": [ 891 + "parent_post_id" 892 + ], 893 + "columnsTo": [ 894 + "id" 895 + ], 896 + "onDelete": "no action", 897 + "onUpdate": "no action" 898 + } 899 + }, 900 + "compositePrimaryKeys": {}, 901 + "uniqueConstraints": {}, 902 + "policies": {}, 903 + "checkConstraints": {}, 904 + "isRLSEnabled": false 905 + }, 906 + "public.roles": { 907 + "name": "roles", 908 + "schema": "", 909 + "columns": { 910 + "id": { 911 + "name": "id", 912 + "type": "bigserial", 913 + "primaryKey": true, 914 + "notNull": true 915 + }, 916 + "did": { 917 + "name": "did", 918 + "type": "text", 919 + "primaryKey": false, 920 + "notNull": true 921 + }, 922 + "rkey": { 923 + "name": "rkey", 924 + "type": "text", 925 + "primaryKey": false, 926 + "notNull": true 927 + }, 928 + "cid": { 929 + "name": "cid", 930 + "type": "text", 931 + "primaryKey": false, 932 + "notNull": true 933 + }, 934 + "name": { 935 + "name": "name", 936 + "type": "text", 937 + "primaryKey": false, 938 + "notNull": true 939 + }, 940 + "description": { 941 + "name": "description", 942 + "type": "text", 943 + "primaryKey": false, 944 + "notNull": false 945 + }, 946 + "permissions": { 947 + "name": "permissions", 948 + "type": "text[]", 949 + "primaryKey": false, 950 + "notNull": true, 951 + "default": "'{}'::text[]" 952 + }, 953 + "priority": { 954 + "name": "priority", 955 + "type": "integer", 956 + "primaryKey": false, 957 + "notNull": true 958 + }, 959 + "created_at": { 960 + "name": "created_at", 961 + "type": "timestamp with time zone", 962 + "primaryKey": false, 963 + "notNull": true 964 + }, 965 + "indexed_at": { 966 + "name": "indexed_at", 967 + "type": "timestamp with time zone", 968 + "primaryKey": false, 969 + "notNull": true 970 + } 971 + }, 972 + "indexes": { 973 + "roles_did_rkey_idx": { 974 + "name": "roles_did_rkey_idx", 975 + "columns": [ 976 + { 977 + "expression": "did", 978 + "isExpression": false, 979 + "asc": true, 980 + "nulls": "last" 981 + }, 982 + { 983 + "expression": "rkey", 984 + "isExpression": false, 985 + "asc": true, 986 + "nulls": "last" 987 + } 988 + ], 989 + "isUnique": true, 990 + "concurrently": false, 991 + "method": "btree", 992 + "with": {} 993 + }, 994 + "roles_did_idx": { 995 + "name": "roles_did_idx", 996 + "columns": [ 997 + { 998 + "expression": "did", 999 + "isExpression": false, 1000 + "asc": true, 1001 + "nulls": "last" 1002 + } 1003 + ], 1004 + "isUnique": false, 1005 + "concurrently": false, 1006 + "method": "btree", 1007 + "with": {} 1008 + }, 1009 + "roles_did_name_idx": { 1010 + "name": "roles_did_name_idx", 1011 + "columns": [ 1012 + { 1013 + "expression": "did", 1014 + "isExpression": false, 1015 + "asc": true, 1016 + "nulls": "last" 1017 + }, 1018 + { 1019 + "expression": "name", 1020 + "isExpression": false, 1021 + "asc": true, 1022 + "nulls": "last" 1023 + } 1024 + ], 1025 + "isUnique": false, 1026 + "concurrently": false, 1027 + "method": "btree", 1028 + "with": {} 1029 + } 1030 + }, 1031 + "foreignKeys": {}, 1032 + "compositePrimaryKeys": {}, 1033 + "uniqueConstraints": {}, 1034 + "policies": {}, 1035 + "checkConstraints": {}, 1036 + "isRLSEnabled": false 1037 + }, 1038 + "public.users": { 1039 + "name": "users", 1040 + "schema": "", 1041 + "columns": { 1042 + "did": { 1043 + "name": "did", 1044 + "type": "text", 1045 + "primaryKey": true, 1046 + "notNull": true 1047 + }, 1048 + "handle": { 1049 + "name": "handle", 1050 + "type": "text", 1051 + "primaryKey": false, 1052 + "notNull": false 1053 + }, 1054 + "indexed_at": { 1055 + "name": "indexed_at", 1056 + "type": "timestamp with time zone", 1057 + "primaryKey": false, 1058 + "notNull": true 1059 + } 1060 + }, 1061 + "indexes": {}, 1062 + "foreignKeys": {}, 1063 + "compositePrimaryKeys": {}, 1064 + "uniqueConstraints": {}, 1065 + "policies": {}, 1066 + "checkConstraints": {}, 1067 + "isRLSEnabled": false 1068 + } 1069 + }, 1070 + "enums": {}, 1071 + "schemas": {}, 1072 + "sequences": {}, 1073 + "roles": {}, 1074 + "policies": {}, 1075 + "views": {}, 1076 + "_meta": { 1077 + "columns": {}, 1078 + "schemas": {}, 1079 + "tables": {} 1080 + } 1081 + }
+14
apps/appview/drizzle/meta/_journal.json
··· 36 36 "when": 1771173166538, 37 37 "tag": "0004_cute_bloodstorm", 38 38 "breakpoints": true 39 + }, 40 + { 41 + "idx": 5, 42 + "version": "7", 43 + "when": 1771283431619, 44 + "tag": "0005_typical_gamora", 45 + "breakpoints": true 46 + }, 47 + { 48 + "idx": 6, 49 + "version": "7", 50 + "when": 1771285570721, 51 + "tag": "0006_absurd_karen_page", 52 + "breakpoints": true 39 53 } 40 54 ] 41 55 }
+8 -6
apps/appview/src/lib/errors.ts
··· 31 31 } 32 32 33 33 /** 34 - * Check if an error represents a database connection failure 35 - * (connection refused, timeout, pool exhausted, network errors). 34 + * Check if an error represents a database-layer failure 35 + * (pool exhausted, postgres-specific errors, query failures). 36 36 * These errors indicate temporary unavailability - user should retry. 37 + * 38 + * Note: Network errors (ECONNREFUSED, timeout, fetch failed) are handled 39 + * separately by isNetworkError and take priority in catch chains. 37 40 */ 38 41 export function isDatabaseError(error: unknown): boolean { 39 42 if (!(error instanceof Error)) return false; 40 43 const msg = error.message.toLowerCase(); 41 44 return ( 42 - msg.includes("connection") || 43 - msg.includes("econnrefused") || 44 - msg.includes("timeout") || 45 45 msg.includes("pool") || 46 46 msg.includes("postgres") || 47 - msg.includes("database") 47 + msg.includes("database") || 48 + msg.includes("sql") || 49 + msg.includes("query") 48 50 ); 49 51 }
+455 -2
apps/appview/src/routes/__tests__/helpers.test.ts
··· 1 - import { describe, it, expect, beforeEach, afterEach } from "vitest"; 1 + import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 2 2 import { isProgrammingError, isNetworkError } from "../../lib/errors.js"; 3 3 import { 4 4 validatePostText, ··· 8 8 serializePost, 9 9 serializeCategory, 10 10 serializeForum, 11 + getActiveBans, 12 + getTopicModStatus, 13 + getHiddenPosts, 11 14 type PostRow, 12 15 type UserRow, 13 16 type CategoryRow, 14 17 type ForumRow, 15 18 } from "../helpers.js"; 16 19 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 17 - import { posts, users } from "@atbb/db"; 20 + import { posts, users, modActions } from "@atbb/db"; 18 21 import { eq } from "drizzle-orm"; 19 22 20 23 describe("validatePostText", () => { ··· 572 575 expect(isNetworkError(new Error("PDS internal error"))).toBe(false); 573 576 }); 574 577 }); 578 + 579 + describe("getActiveBans", () => { 580 + let ctx: TestContext; 581 + 582 + beforeEach(async () => { 583 + ctx = await createTestContext(); 584 + }); 585 + 586 + afterEach(async () => { 587 + await ctx.cleanup(); 588 + }); 589 + 590 + it("returns empty set when no bans exist", async () => { 591 + const banned = await getActiveBans(ctx.db, ["did:plc:user1", "did:plc:user2"]); 592 + 593 + expect(banned.size).toBe(0); 594 + }); 595 + 596 + it("returns banned users from active ban actions", async () => { 597 + // Insert a ban action 598 + await ctx.db.insert(modActions).values({ 599 + did: ctx.config.forumDid, 600 + rkey: "ban1", 601 + cid: "bafy...1", 602 + action: "space.atbb.modAction.ban", 603 + subjectDid: "did:plc:banned-user", 604 + createdBy: "did:plc:admin", 605 + createdAt: new Date(), 606 + indexedAt: new Date(), 607 + }); 608 + 609 + const banned = await getActiveBans(ctx.db, [ 610 + "did:plc:banned-user", 611 + "did:plc:normal-user" 612 + ]); 613 + 614 + expect(banned.size).toBe(1); 615 + expect(banned.has("did:plc:banned-user")).toBe(true); 616 + expect(banned.has("did:plc:normal-user")).toBe(false); 617 + }); 618 + 619 + it("excludes users with reversed bans (unban action)", async () => { 620 + // Ban then unban 621 + await ctx.db.insert(modActions).values([ 622 + { 623 + did: ctx.config.forumDid, 624 + rkey: "ban1", 625 + cid: "bafy...1", 626 + action: "space.atbb.modAction.ban", 627 + subjectDid: "did:plc:user1", 628 + createdBy: "did:plc:admin", 629 + createdAt: new Date("2026-01-01"), 630 + indexedAt: new Date("2026-01-01"), 631 + }, 632 + { 633 + did: ctx.config.forumDid, 634 + rkey: "ban2", 635 + cid: "bafy...2", 636 + action: "space.atbb.modAction.unban", 637 + subjectDid: "did:plc:user1", 638 + createdBy: "did:plc:admin", 639 + createdAt: new Date("2026-01-02"), 640 + indexedAt: new Date("2026-01-02"), 641 + } 642 + ]); 643 + 644 + const banned = await getActiveBans(ctx.db, ["did:plc:user1"]); 645 + 646 + expect(banned.size).toBe(0); 647 + }); 648 + 649 + it("returns empty set for empty input array", async () => { 650 + const banned = await getActiveBans(ctx.db, []); 651 + 652 + expect(banned.size).toBe(0); 653 + }); 654 + 655 + it("throws when database query fails", async () => { 656 + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 657 + const dbError = new Error("Database connection lost"); 658 + const chainMock = { 659 + from: vi.fn().mockReturnThis(), 660 + where: vi.fn().mockReturnThis(), 661 + orderBy: vi.fn().mockReturnThis(), 662 + limit: vi.fn().mockRejectedValue(dbError), 663 + }; 664 + const mockDb = { select: vi.fn().mockReturnValue(chainMock) } as any; 665 + 666 + await expect(getActiveBans(mockDb, ["did:plc:user1"])).rejects.toThrow("Database connection lost"); 667 + 668 + expect(consoleErrorSpy).toHaveBeenCalledWith( 669 + "Failed to query active bans", 670 + expect.objectContaining({ operation: "getActiveBans" }) 671 + ); 672 + consoleErrorSpy.mockRestore(); 673 + }); 674 + }); 675 + 676 + describe("getTopicModStatus", () => { 677 + let ctx: TestContext; 678 + 679 + beforeEach(async () => { 680 + ctx = await createTestContext(); 681 + }); 682 + 683 + afterEach(async () => { 684 + await ctx.cleanup(); 685 + }); 686 + 687 + it("returns unlocked/unpinned when no mod actions exist", async () => { 688 + const status = await getTopicModStatus(ctx.db, BigInt(999)); 689 + 690 + expect(status.locked).toBe(false); 691 + expect(status.pinned).toBe(false); 692 + }); 693 + 694 + it("returns locked=true when most recent action is lock", async () => { 695 + // Create user first (posts table has FK to users) 696 + await ctx.db.insert(users).values({ 697 + did: "did:plc:topicmodtest-lock", 698 + handle: "lockuser.test", 699 + indexedAt: new Date(), 700 + }).onConflictDoNothing(); 701 + 702 + // Create a post with unique rkey 703 + const rkey = `post-lock-test-${Date.now()}`; 704 + const [post] = await ctx.db.insert(posts).values({ 705 + did: "did:plc:topicmodtest-lock", 706 + rkey, 707 + cid: `bafy...lock-test-${Date.now()}`, 708 + text: "Topic text", 709 + forumUri: "at://forum/space.atbb.forum.forum/self", 710 + createdAt: new Date(), 711 + indexedAt: new Date(), 712 + }).returning(); 713 + 714 + const postUri = `at://did:plc:topicmodtest-lock/space.atbb.post/${rkey}`; 715 + 716 + // Lock the topic 717 + await ctx.db.insert(modActions).values({ 718 + did: ctx.config.forumDid, 719 + rkey: `lock-action-test-${Date.now()}`, 720 + cid: `bafy...lock-action-${Date.now()}`, 721 + action: "space.atbb.modAction.lock", 722 + subjectPostUri: postUri, 723 + createdBy: "did:plc:mod", 724 + createdAt: new Date(), 725 + indexedAt: new Date(), 726 + }); 727 + 728 + const status = await getTopicModStatus(ctx.db, post.id); 729 + 730 + expect(status.locked).toBe(true); 731 + expect(status.pinned).toBe(false); 732 + }); 733 + 734 + it("returns pinned=true when most recent action is pin", async () => { 735 + // Create user first 736 + await ctx.db.insert(users).values({ 737 + did: "did:plc:topicmodtest-pin", 738 + handle: "pinuser.test", 739 + indexedAt: new Date(), 740 + }).onConflictDoNothing(); 741 + 742 + const rkey = `post-pin-test-${Date.now()}`; 743 + const [post] = await ctx.db.insert(posts).values({ 744 + did: "did:plc:topicmodtest-pin", 745 + rkey, 746 + cid: `bafy...pin-test-${Date.now()}`, 747 + text: "Pinned topic", 748 + forumUri: "at://forum/space.atbb.forum.forum/self", 749 + createdAt: new Date(), 750 + indexedAt: new Date(), 751 + }).returning(); 752 + 753 + const postUri = `at://did:plc:topicmodtest-pin/space.atbb.post/${rkey}`; 754 + 755 + await ctx.db.insert(modActions).values({ 756 + did: ctx.config.forumDid, 757 + rkey: `pin-action-test-${Date.now()}`, 758 + cid: `bafy...pin-action-${Date.now()}`, 759 + action: "space.atbb.modAction.pin", 760 + subjectPostUri: postUri, 761 + createdBy: "did:plc:mod", 762 + createdAt: new Date(), 763 + indexedAt: new Date(), 764 + }); 765 + 766 + const status = await getTopicModStatus(ctx.db, post.id); 767 + 768 + expect(status.locked).toBe(false); 769 + expect(status.pinned).toBe(true); 770 + }); 771 + 772 + it("handles unlock reversing lock", async () => { 773 + // Create user first 774 + await ctx.db.insert(users).values({ 775 + did: "did:plc:topicmodtest-unlock", 776 + handle: "unlockuser.test", 777 + indexedAt: new Date(), 778 + }).onConflictDoNothing(); 779 + 780 + const rkey = `post-unlock-test-${Date.now()}`; 781 + const [post] = await ctx.db.insert(posts).values({ 782 + did: "did:plc:topicmodtest-unlock", 783 + rkey, 784 + cid: `bafy...unlock-test-${Date.now()}`, 785 + text: "Unlocked topic", 786 + forumUri: "at://forum/space.atbb.forum.forum/self", 787 + createdAt: new Date(), 788 + indexedAt: new Date(), 789 + }).returning(); 790 + 791 + const postUri = `at://did:plc:topicmodtest-unlock/space.atbb.post/${rkey}`; 792 + 793 + // Lock then unlock 794 + const timestamp = Date.now(); 795 + await ctx.db.insert(modActions).values([ 796 + { 797 + did: ctx.config.forumDid, 798 + rkey: `lock-unlock-test-1-${timestamp}`, 799 + cid: `bafy...lock-unlock-1-${timestamp}`, 800 + action: "space.atbb.modAction.lock", 801 + subjectPostUri: postUri, 802 + createdBy: "did:plc:mod", 803 + createdAt: new Date("2026-01-01"), 804 + indexedAt: new Date("2026-01-01"), 805 + }, 806 + { 807 + did: ctx.config.forumDid, 808 + rkey: `lock-unlock-test-2-${timestamp}`, 809 + cid: `bafy...lock-unlock-2-${timestamp}`, 810 + action: "space.atbb.modAction.unlock", 811 + subjectPostUri: postUri, 812 + createdBy: "did:plc:mod", 813 + createdAt: new Date("2026-01-02"), 814 + indexedAt: new Date("2026-01-02"), 815 + } 816 + ]); 817 + 818 + const status = await getTopicModStatus(ctx.db, post.id); 819 + 820 + expect(status.locked).toBe(false); 821 + expect(status.pinned).toBe(false); 822 + }); 823 + 824 + it("throws when database query fails", async () => { 825 + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 826 + const dbError = new Error("Database connection lost"); 827 + const chainMock = { 828 + from: vi.fn().mockReturnThis(), 829 + where: vi.fn().mockReturnThis(), 830 + limit: vi.fn().mockRejectedValue(dbError), 831 + orderBy: vi.fn().mockRejectedValue(dbError), 832 + }; 833 + const mockDb = { select: vi.fn().mockReturnValue(chainMock) } as any; 834 + 835 + await expect(getTopicModStatus(mockDb, BigInt(1))).rejects.toThrow("Database connection lost"); 836 + 837 + expect(consoleErrorSpy).toHaveBeenCalledWith( 838 + "Failed to query topic moderation status", 839 + expect.objectContaining({ operation: "getTopicModStatus" }) 840 + ); 841 + consoleErrorSpy.mockRestore(); 842 + }); 843 + }); 844 + 845 + describe("getHiddenPosts", () => { 846 + let ctx: TestContext; 847 + 848 + beforeEach(async () => { 849 + ctx = await createTestContext(); 850 + }); 851 + 852 + afterEach(async () => { 853 + await ctx.cleanup(); 854 + }); 855 + 856 + it("returns empty set when no posts are hidden", async () => { 857 + // Create user first 858 + await ctx.db.insert(users).values({ 859 + did: "did:plc:hiddentest-user1", 860 + handle: "user1.test", 861 + indexedAt: new Date(), 862 + }).onConflictDoNothing(); 863 + 864 + // Create posts without any hide actions 865 + const timestamp = Date.now(); 866 + const [post1] = await ctx.db.insert(posts).values({ 867 + did: "did:plc:hiddentest-user1", 868 + rkey: `post-visible-1-${timestamp}`, 869 + cid: `bafy...visible-1-${timestamp}`, 870 + text: "Visible post 1", 871 + forumUri: "at://forum/space.atbb.forum.forum/self", 872 + createdAt: new Date(), 873 + indexedAt: new Date(), 874 + }).returning(); 875 + 876 + const [post2] = await ctx.db.insert(posts).values({ 877 + did: "did:plc:hiddentest-user1", 878 + rkey: `post-visible-2-${timestamp}`, 879 + cid: `bafy...visible-2-${timestamp}`, 880 + text: "Visible post 2", 881 + forumUri: "at://forum/space.atbb.forum.forum/self", 882 + createdAt: new Date(), 883 + indexedAt: new Date(), 884 + }).returning(); 885 + 886 + const hidden = await getHiddenPosts(ctx.db, [post1.id, post2.id]); 887 + 888 + expect(hidden.size).toBe(0); 889 + }); 890 + 891 + it("returns post IDs with active hide actions", async () => { 892 + // Create users 893 + await ctx.db.insert(users).values([ 894 + { 895 + did: "did:plc:hiddentest-user2", 896 + handle: "user2.test", 897 + indexedAt: new Date(), 898 + }, 899 + { 900 + did: "did:plc:hiddentest-user3", 901 + handle: "user3.test", 902 + indexedAt: new Date(), 903 + } 904 + ]).onConflictDoNothing(); 905 + 906 + // Create posts 907 + const timestamp = Date.now(); 908 + const [hiddenPost] = await ctx.db.insert(posts).values({ 909 + did: "did:plc:hiddentest-user2", 910 + rkey: `post-hidden-${timestamp}`, 911 + cid: `bafy...hidden-${timestamp}`, 912 + text: "Hidden post", 913 + forumUri: "at://forum/space.atbb.forum.forum/self", 914 + createdAt: new Date(), 915 + indexedAt: new Date(), 916 + }).returning(); 917 + 918 + const [visiblePost] = await ctx.db.insert(posts).values({ 919 + did: "did:plc:hiddentest-user3", 920 + rkey: `post-visible-${timestamp}`, 921 + cid: `bafy...visible-${timestamp}`, 922 + text: "Visible post", 923 + forumUri: "at://forum/space.atbb.forum.forum/self", 924 + createdAt: new Date(), 925 + indexedAt: new Date(), 926 + }).returning(); 927 + 928 + const hiddenUri = `at://did:plc:hiddentest-user2/space.atbb.post/post-hidden-${timestamp}`; 929 + 930 + // Hide one post 931 + await ctx.db.insert(modActions).values({ 932 + did: ctx.config.forumDid, 933 + rkey: `hide-action-${timestamp}`, 934 + cid: `bafy...hide-${timestamp}`, 935 + action: "space.atbb.modAction.delete", 936 + subjectPostUri: hiddenUri, 937 + createdBy: "did:plc:mod", 938 + createdAt: new Date(), 939 + indexedAt: new Date(), 940 + }); 941 + 942 + const hidden = await getHiddenPosts(ctx.db, [hiddenPost.id, visiblePost.id]); 943 + 944 + expect(hidden.size).toBe(1); 945 + expect(hidden.has(hiddenPost.id)).toBe(true); 946 + expect(hidden.has(visiblePost.id)).toBe(false); 947 + }); 948 + 949 + it("excludes posts with reversed hide actions (undelete after delete)", async () => { 950 + // Create user 951 + await ctx.db.insert(users).values({ 952 + did: "did:plc:hiddentest-user4", 953 + handle: "user4.test", 954 + indexedAt: new Date(), 955 + }).onConflictDoNothing(); 956 + 957 + const timestamp = Date.now(); 958 + const [post] = await ctx.db.insert(posts).values({ 959 + did: "did:plc:hiddentest-user4", 960 + rkey: `post-restored-${timestamp}`, 961 + cid: `bafy...restored-${timestamp}`, 962 + text: "Restored post", 963 + forumUri: "at://forum/space.atbb.forum.forum/self", 964 + createdAt: new Date(), 965 + indexedAt: new Date(), 966 + }).returning(); 967 + 968 + const postUri = `at://did:plc:hiddentest-user4/space.atbb.post/post-restored-${timestamp}`; 969 + 970 + // Hide then restore 971 + await ctx.db.insert(modActions).values([ 972 + { 973 + did: ctx.config.forumDid, 974 + rkey: `hide-restore-1-${timestamp}`, 975 + cid: `bafy...hide-restore-1-${timestamp}`, 976 + action: "space.atbb.modAction.delete", 977 + subjectPostUri: postUri, 978 + createdBy: "did:plc:mod", 979 + createdAt: new Date("2026-01-01"), 980 + indexedAt: new Date("2026-01-01"), 981 + }, 982 + { 983 + did: ctx.config.forumDid, 984 + rkey: `hide-restore-2-${timestamp}`, 985 + cid: `bafy...hide-restore-2-${timestamp}`, 986 + action: "space.atbb.modAction.undelete", 987 + subjectPostUri: postUri, 988 + createdBy: "did:plc:mod", 989 + createdAt: new Date("2026-01-02"), 990 + indexedAt: new Date("2026-01-02"), 991 + } 992 + ]); 993 + 994 + const hidden = await getHiddenPosts(ctx.db, [post.id]); 995 + 996 + expect(hidden.size).toBe(0); 997 + }); 998 + 999 + it("returns empty set for empty input array", async () => { 1000 + const hidden = await getHiddenPosts(ctx.db, []); 1001 + 1002 + expect(hidden.size).toBe(0); 1003 + }); 1004 + 1005 + it("throws when database query fails", async () => { 1006 + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 1007 + const dbError = new Error("Database connection lost"); 1008 + const chainMock = { 1009 + from: vi.fn().mockReturnThis(), 1010 + where: vi.fn().mockReturnThis(), 1011 + limit: vi.fn().mockRejectedValue(dbError), 1012 + orderBy: vi.fn().mockRejectedValue(dbError), 1013 + }; 1014 + const mockDb = { select: vi.fn().mockReturnValue(chainMock) } as any; 1015 + 1016 + await expect(getHiddenPosts(mockDb, [BigInt(1), BigInt(2)])).rejects.toThrow("Database connection lost"); 1017 + 1018 + expect(consoleErrorSpy).toHaveBeenCalledWith( 1019 + "Failed to query hidden posts", 1020 + expect.objectContaining({ 1021 + operation: "getHiddenPosts", 1022 + postIdCount: 2, 1023 + }) 1024 + ); 1025 + consoleErrorSpy.mockRestore(); 1026 + }); 1027 + });
+399 -1
apps/appview/src/routes/__tests__/posts.test.ts
··· 2 2 import { Hono } from "hono"; 3 3 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 4 4 import type { Variables } from "../../types.js"; 5 - import { posts, users } from "@atbb/db"; 5 + import { posts, users, modActions } from "@atbb/db"; 6 + import { TID } from "@atproto/common-web"; 6 7 7 8 // Mock auth and permission middleware at the module level 8 9 let mockPutRecord: ReturnType<typeof vi.fn>; ··· 359 360 expect(res.status).toBe(500); 360 361 const data = await res.json(); 361 362 expect(data.error).toContain("Failed to create post"); 363 + }); 364 + 365 + it("returns 503 when database is unavailable during post creation", async () => { 366 + const helpers = await import("../helpers.js"); 367 + const getPostsByIdsSpy = vi.spyOn(helpers, "getPostsByIds"); 368 + getPostsByIdsSpy.mockRejectedValueOnce(new Error("Database connection lost")); 369 + 370 + const res = await app.request("/api/posts", { 371 + method: "POST", 372 + headers: { "Content-Type": "application/json" }, 373 + body: JSON.stringify({ 374 + text: "Test reply", 375 + rootPostId: topicId, 376 + parentPostId: topicId, 377 + }), 378 + }); 379 + 380 + expect(res.status).toBe(503); 381 + const data = await res.json(); 382 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 383 + 384 + getPostsByIdsSpy.mockRestore(); 385 + }); 386 + }); 387 + 388 + describe("POST /api/posts - ban enforcement", () => { 389 + let ctx: TestContext; 390 + let app: Hono<{ Variables: Variables }>; 391 + let topicId: string; 392 + 393 + beforeEach(async () => { 394 + ctx = await createTestContext(); 395 + 396 + // Insert test user (use onConflictDoNothing in case tests share users) 397 + await ctx.db.insert(users).values({ 398 + did: "did:plc:ban-test-user", 399 + handle: "bantestuser.test", 400 + indexedAt: new Date(), 401 + }).onConflictDoNothing(); 402 + 403 + // Insert topic (root post) with unique rkey 404 + const banTopicRkey = TID.nextStr(); 405 + const [topicPost] = await ctx.db 406 + .insert(posts) 407 + .values({ 408 + did: "did:plc:ban-test-user", 409 + rkey: banTopicRkey, 410 + cid: `bafy${banTopicRkey}`, 411 + text: "Topic for ban tests", 412 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 413 + createdAt: new Date(), 414 + indexedAt: new Date(), 415 + deleted: false, 416 + }) 417 + .returning({ id: posts.id }); 418 + 419 + topicId = topicPost.id.toString(); 420 + 421 + // Set up mock user 422 + mockUser = { 423 + did: "did:plc:ban-test-user", 424 + handle: "bantestuser.test", 425 + pdsUrl: "https://test.pds", 426 + agent: { 427 + com: { 428 + atproto: { 429 + repo: { 430 + putRecord: vi.fn(async () => ({ 431 + data: { 432 + uri: "at://did:plc:ban-test-user/space.atbb.post/3lbkbanreply", 433 + cid: "bafybanreply", 434 + }, 435 + })), 436 + }, 437 + }, 438 + }, 439 + }, 440 + }; 441 + 442 + app = new Hono<{ Variables: Variables }>(); 443 + app.route("/api/posts", createPostsRoutes(ctx)); 444 + }); 445 + 446 + afterEach(async () => { 447 + await ctx.cleanup(); 448 + }); 449 + 450 + it("allows non-banned user to create reply", async () => { 451 + const res = await app.request("/api/posts", { 452 + method: "POST", 453 + headers: { "Content-Type": "application/json" }, 454 + body: JSON.stringify({ 455 + text: "Reply from non-banned user", 456 + rootPostId: topicId, 457 + parentPostId: topicId, 458 + }), 459 + }); 460 + 461 + expect(res.status).toBe(201); 462 + const data = await res.json(); 463 + expect(data.uri).toBeDefined(); 464 + }); 465 + 466 + it("blocks banned user from creating reply", async () => { 467 + // Ban the user 468 + const banRkey = TID.nextStr(); 469 + await ctx.db.insert(modActions).values({ 470 + did: ctx.config.forumDid, 471 + rkey: banRkey, 472 + cid: `bafy${banRkey}`, 473 + action: "space.atbb.modAction.ban", 474 + subjectDid: mockUser.did, 475 + createdBy: "did:plc:admin", 476 + createdAt: new Date(), 477 + indexedAt: new Date(), 478 + }); 479 + 480 + const res = await app.request("/api/posts", { 481 + method: "POST", 482 + headers: { "Content-Type": "application/json" }, 483 + body: JSON.stringify({ 484 + text: "Reply from banned user", 485 + rootPostId: topicId, 486 + parentPostId: topicId, 487 + }), 488 + }); 489 + 490 + expect(res.status).toBe(403); 491 + const data = await res.json(); 492 + expect(data.error).toBe("You are banned from this forum"); 493 + }); 494 + 495 + it("returns 503 when ban check fails with database error", async () => { 496 + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 497 + 498 + const helpers = await import("../helpers.js"); 499 + const getActiveBansSpy = vi.spyOn(helpers, "getActiveBans"); 500 + getActiveBansSpy.mockRejectedValueOnce(new Error("Database connection lost")); 501 + 502 + const res = await app.request("/api/posts", { 503 + method: "POST", 504 + headers: { "Content-Type": "application/json" }, 505 + body: JSON.stringify({ 506 + text: "Reply attempt during DB error", 507 + rootPostId: topicId, 508 + parentPostId: topicId, 509 + }), 510 + }); 511 + 512 + expect(res.status).toBe(503); 513 + const data = await res.json(); 514 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 515 + 516 + expect(consoleErrorSpy).toHaveBeenCalledWith( 517 + "Failed to check ban status", 518 + expect.objectContaining({ 519 + operation: "POST /api/posts - ban check", 520 + userId: mockUser.did, 521 + error: "Database connection lost", 522 + }) 523 + ); 524 + 525 + consoleErrorSpy.mockRestore(); 526 + getActiveBansSpy.mockRestore(); 527 + }); 528 + 529 + it("returns 500 when ban check fails with unexpected error (fail closed)", async () => { 530 + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 531 + 532 + const helpers = await import("../helpers.js"); 533 + const getActiveBansSpy = vi.spyOn(helpers, "getActiveBans"); 534 + getActiveBansSpy.mockRejectedValueOnce(new Error("Unexpected internal error")); 535 + 536 + const res = await app.request("/api/posts", { 537 + method: "POST", 538 + headers: { "Content-Type": "application/json" }, 539 + body: JSON.stringify({ 540 + text: "Reply attempt during unexpected error", 541 + rootPostId: topicId, 542 + parentPostId: topicId, 543 + }), 544 + }); 545 + 546 + expect(res.status).toBe(500); 547 + const data = await res.json(); 548 + expect(data.error).toBe("Unable to verify permissions. Please try again later."); 549 + 550 + expect(consoleErrorSpy).toHaveBeenCalledWith( 551 + "Failed to check ban status", 552 + expect.objectContaining({ 553 + operation: "POST /api/posts - ban check", 554 + userId: mockUser.did, 555 + error: "Unexpected internal error", 556 + }) 557 + ); 558 + 559 + consoleErrorSpy.mockRestore(); 560 + getActiveBansSpy.mockRestore(); 561 + }); 562 + 563 + it("re-throws programming errors from ban check", async () => { 564 + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 565 + 566 + const helpers = await import("../helpers.js"); 567 + const getActiveBansSpy = vi.spyOn(helpers, "getActiveBans"); 568 + getActiveBansSpy.mockImplementationOnce(() => { 569 + throw new TypeError("Cannot read property 'has' of undefined"); 570 + }); 571 + 572 + // Hono catches re-thrown errors via its internal error handler 573 + const res = await app.request("/api/posts", { 574 + method: "POST", 575 + headers: { "Content-Type": "application/json" }, 576 + body: JSON.stringify({ 577 + text: "Reply with programming error", 578 + rootPostId: topicId, 579 + parentPostId: topicId, 580 + }), 581 + }); 582 + 583 + // Hono's default error handler returns 500 for uncaught throws 584 + expect(res.status).toBe(500); 585 + 586 + // Verify CRITICAL error was logged (proves the re-throw path was executed) 587 + expect(consoleErrorSpy).toHaveBeenCalledWith( 588 + "CRITICAL: Programming error in ban check", 589 + expect.objectContaining({ 590 + operation: "POST /api/posts - ban check", 591 + userId: mockUser.did, 592 + error: "Cannot read property 'has' of undefined", 593 + stack: expect.any(String), 594 + }) 595 + ); 596 + 597 + // Verify the normal error path was NOT taken 598 + expect(consoleErrorSpy).not.toHaveBeenCalledWith( 599 + "Failed to check ban status", 600 + expect.any(Object) 601 + ); 602 + 603 + consoleErrorSpy.mockRestore(); 604 + getActiveBansSpy.mockRestore(); 605 + }); 606 + }); 607 + 608 + describe("POST /api/posts - lock enforcement", () => { 609 + let ctx: TestContext; 610 + let app: Hono<{ Variables: Variables }>; 611 + let topicId: string; 612 + let topicRkey: string; 613 + 614 + beforeEach(async () => { 615 + ctx = await createTestContext(); 616 + 617 + // Insert test user (use onConflictDoNothing in case tests share users) 618 + await ctx.db.insert(users).values({ 619 + did: "did:plc:lock-test-user", 620 + handle: "locktestuser.test", 621 + indexedAt: new Date(), 622 + }).onConflictDoNothing(); 623 + 624 + // Insert topic (root post) with unique rkey 625 + topicRkey = TID.nextStr(); 626 + const [topicPost] = await ctx.db 627 + .insert(posts) 628 + .values({ 629 + did: "did:plc:lock-test-user", 630 + rkey: topicRkey, 631 + cid: `bafy${topicRkey}`, 632 + text: "Topic for lock tests", 633 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 634 + createdAt: new Date(), 635 + indexedAt: new Date(), 636 + deleted: false, 637 + }) 638 + .returning({ id: posts.id }); 639 + 640 + topicId = topicPost.id.toString(); 641 + 642 + // Set up mock user 643 + mockUser = { 644 + did: "did:plc:lock-test-user", 645 + handle: "locktestuser.test", 646 + pdsUrl: "https://test.pds", 647 + agent: { 648 + com: { 649 + atproto: { 650 + repo: { 651 + putRecord: vi.fn(async () => ({ 652 + data: { 653 + uri: "at://did:plc:lock-test-user/space.atbb.post/3lbklockreply", 654 + cid: "bafylockreply", 655 + }, 656 + })), 657 + }, 658 + }, 659 + }, 660 + }, 661 + }; 662 + 663 + app = new Hono<{ Variables: Variables }>(); 664 + app.route("/api/posts", createPostsRoutes(ctx)); 665 + }); 666 + 667 + afterEach(async () => { 668 + await ctx.cleanup(); 669 + }); 670 + 671 + it("allows reply when topic is unlocked", async () => { 672 + const res = await app.request("/api/posts", { 673 + method: "POST", 674 + headers: { "Content-Type": "application/json" }, 675 + body: JSON.stringify({ 676 + text: "Reply to unlocked topic", 677 + rootPostId: topicId, 678 + parentPostId: topicId, 679 + }), 680 + }); 681 + 682 + expect(res.status).toBe(201); 683 + const data = await res.json(); 684 + expect(data.uri).toBeDefined(); 685 + }); 686 + 687 + it("blocks reply when topic is locked", async () => { 688 + // Lock the topic 689 + const lockRkey = TID.nextStr(); 690 + const topicUri = `at://did:plc:lock-test-user/space.atbb.post/${topicRkey}`; 691 + 692 + await ctx.db.insert(modActions).values({ 693 + did: ctx.config.forumDid, 694 + rkey: lockRkey, 695 + cid: `bafy${lockRkey}`, 696 + action: "space.atbb.modAction.lock", 697 + subjectPostUri: topicUri, 698 + createdBy: "did:plc:admin", 699 + createdAt: new Date(), 700 + indexedAt: new Date(), 701 + }); 702 + 703 + const res = await app.request("/api/posts", { 704 + method: "POST", 705 + headers: { "Content-Type": "application/json" }, 706 + body: JSON.stringify({ 707 + text: "Reply to locked topic", 708 + rootPostId: topicId, 709 + parentPostId: topicId, 710 + }), 711 + }); 712 + 713 + expect(res.status).toBe(403); 714 + const data = await res.json(); 715 + expect(data.error).toContain("locked"); 716 + }); 717 + 718 + it("allows reply when topic was locked then unlocked", async () => { 719 + const topicUri = `at://did:plc:lock-test-user/space.atbb.post/${topicRkey}`; 720 + 721 + // Lock the topic first 722 + const lockRkey = TID.nextStr(); 723 + await ctx.db.insert(modActions).values({ 724 + did: ctx.config.forumDid, 725 + rkey: lockRkey, 726 + cid: `bafy${lockRkey}`, 727 + action: "space.atbb.modAction.lock", 728 + subjectPostUri: topicUri, 729 + createdBy: "did:plc:admin", 730 + createdAt: new Date("2024-01-01T00:00:00Z"), 731 + indexedAt: new Date("2024-01-01T00:00:00Z"), 732 + }); 733 + 734 + // Then unlock the topic (more recent action) 735 + const unlockRkey = TID.nextStr(); 736 + await ctx.db.insert(modActions).values({ 737 + did: ctx.config.forumDid, 738 + rkey: unlockRkey, 739 + cid: `bafy${unlockRkey}`, 740 + action: "space.atbb.modAction.unlock", 741 + subjectPostUri: topicUri, 742 + createdBy: "did:plc:admin", 743 + createdAt: new Date("2024-01-02T00:00:00Z"), 744 + indexedAt: new Date("2024-01-02T00:00:00Z"), 745 + }); 746 + 747 + const res = await app.request("/api/posts", { 748 + method: "POST", 749 + headers: { "Content-Type": "application/json" }, 750 + body: JSON.stringify({ 751 + text: "Reply to re-opened topic", 752 + rootPostId: topicId, 753 + parentPostId: topicId, 754 + }), 755 + }); 756 + 757 + expect(res.status).toBe(201); 758 + const data = await res.json(); 759 + expect(data.uri).toBeDefined(); 362 760 }); 363 761 });
+808 -1
apps/appview/src/routes/__tests__/topics.test.ts
··· 2 2 import { Hono } from "hono"; 3 3 import type { Variables } from "../../types.js"; 4 4 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 5 - import { forums, categories, boards } from "@atbb/db"; 5 + import { forums, categories, boards, users, posts, modActions } from "@atbb/db"; 6 6 import { eq } from "drizzle-orm"; 7 + import { TID } from "@atproto/common-web"; 7 8 8 9 // Mock auth and permission middleware at the module level 9 10 let mockPutRecord: ReturnType<typeof vi.fn>; ··· 483 484 expect(data.error).toBe("boardUri must belong to this forum"); 484 485 }); 485 486 }); 487 + 488 + describe.sequential("GET /api/topics/:id - moderation enforcement", () => { 489 + let ctx: TestContext; 490 + let app: Hono; 491 + 492 + beforeEach(async () => { 493 + ctx = await createTestContext(); 494 + app = new Hono().route("/api/topics", createTopicsRoutes(ctx)); 495 + 496 + // Insert test users (use onConflictDoNothing in case tests share users) 497 + await ctx.db.insert(users).values([ 498 + { 499 + did: "did:plc:mod-test-user1", 500 + handle: "moduser1.test", 501 + indexedAt: new Date(), 502 + }, 503 + { 504 + did: "did:plc:mod-test-user2", 505 + handle: "moduser2.test", 506 + indexedAt: new Date(), 507 + }, 508 + ]).onConflictDoNothing(); 509 + }); 510 + 511 + afterEach(async () => { 512 + await ctx.cleanup(); 513 + }); 514 + 515 + it("excludes posts from banned users", async () => { 516 + // Generate unique rkeys for this test run 517 + const topicRkey = TID.nextStr(); 518 + const reply1Rkey = TID.nextStr(); 519 + const reply2Rkey = TID.nextStr(); 520 + 521 + // Insert a topic (root post) 522 + const [topic] = await ctx.db 523 + .insert(posts) 524 + .values({ 525 + did: "did:plc:mod-test-user1", 526 + rkey: topicRkey, 527 + cid: `bafy${topicRkey}`, 528 + text: "Topic post", 529 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 530 + createdAt: new Date(), 531 + indexedAt: new Date(), 532 + }) 533 + .returning(); 534 + 535 + const topicId = topic.id; 536 + 537 + // Insert two replies - one from mod-test-user1, one from mod-test-user2 538 + await ctx.db.insert(posts).values([ 539 + { 540 + did: "did:plc:mod-test-user1", 541 + rkey: reply1Rkey, 542 + cid: `bafy${reply1Rkey}`, 543 + text: "Reply from user1", 544 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 545 + rootPostId: topicId, 546 + parentPostId: topicId, 547 + createdAt: new Date(), 548 + indexedAt: new Date(), 549 + }, 550 + { 551 + did: "did:plc:mod-test-user2", 552 + rkey: reply2Rkey, 553 + cid: `bafy${reply2Rkey}`, 554 + text: "Reply from user2", 555 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 556 + rootPostId: topicId, 557 + parentPostId: topicId, 558 + createdAt: new Date(), 559 + indexedAt: new Date(), 560 + }, 561 + ]); 562 + 563 + // Ban mod-test-user2 564 + const banRkey = TID.nextStr(); 565 + await ctx.db.insert(modActions).values({ 566 + did: ctx.config.forumDid, 567 + rkey: banRkey, 568 + cid: `bafy${banRkey}`, 569 + action: "space.atbb.modAction.ban", 570 + subjectDid: "did:plc:mod-test-user2", 571 + createdBy: "did:plc:admin", 572 + createdAt: new Date(), 573 + indexedAt: new Date(), 574 + }); 575 + 576 + // Query the topic 577 + const res = await app.request(`/api/topics/${topicId.toString()}`); 578 + 579 + expect(res.status).toBe(200); 580 + const data = await res.json(); 581 + 582 + // Should only include mod-test-user1's reply (mod-test-user2 is banned) 583 + expect(data.replies).toHaveLength(1); 584 + expect(data.replies[0].author.did).toBe("did:plc:mod-test-user1"); 585 + }); 586 + 587 + it("includes replies when ban is reversed", async () => { 588 + // Generate unique rkeys for this test run 589 + const topicRkey = TID.nextStr(); 590 + const replyRkey = TID.nextStr(); 591 + 592 + // Insert a topic (root post) 593 + const [topic] = await ctx.db 594 + .insert(posts) 595 + .values({ 596 + did: "did:plc:mod-test-user1", 597 + rkey: topicRkey, 598 + cid: `bafy${topicRkey}`, 599 + text: "Topic post", 600 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 601 + createdAt: new Date(), 602 + indexedAt: new Date(), 603 + }) 604 + .returning(); 605 + 606 + const topicId = topic.id; 607 + 608 + // Insert a reply from mod-test-user1 609 + await ctx.db.insert(posts).values({ 610 + did: "did:plc:mod-test-user1", 611 + rkey: replyRkey, 612 + cid: `bafy${replyRkey}`, 613 + text: "Reply from user1", 614 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 615 + rootPostId: topicId, 616 + parentPostId: topicId, 617 + createdAt: new Date(), 618 + indexedAt: new Date(), 619 + }); 620 + 621 + // Ban mod-test-user1 622 + const banRkey = TID.nextStr(); 623 + await ctx.db.insert(modActions).values({ 624 + did: ctx.config.forumDid, 625 + rkey: banRkey, 626 + cid: `bafy${banRkey}`, 627 + action: "space.atbb.modAction.ban", 628 + subjectDid: "did:plc:mod-test-user1", 629 + createdBy: "did:plc:admin", 630 + createdAt: new Date("2024-01-01T00:00:00Z"), 631 + indexedAt: new Date("2024-01-01T00:00:00Z"), 632 + }); 633 + 634 + // Unban mod-test-user1 (more recent action) 635 + const unbanRkey = TID.nextStr(); 636 + await ctx.db.insert(modActions).values({ 637 + did: ctx.config.forumDid, 638 + rkey: unbanRkey, 639 + cid: `bafy${unbanRkey}`, 640 + action: "space.atbb.modAction.unban", 641 + subjectDid: "did:plc:mod-test-user1", 642 + createdBy: "did:plc:admin", 643 + createdAt: new Date("2024-01-02T00:00:00Z"), 644 + indexedAt: new Date("2024-01-02T00:00:00Z"), 645 + }); 646 + 647 + // Query the topic 648 + const res = await app.request(`/api/topics/${topicId.toString()}`); 649 + 650 + expect(res.status).toBe(200); 651 + const data = await res.json(); 652 + 653 + // Should include mod-test-user1's reply (unban reversed the ban) 654 + expect(data.replies).toHaveLength(1); 655 + expect(data.replies[0].author.did).toBe("did:plc:mod-test-user1"); 656 + }); 657 + }); 658 + 659 + describe("POST /api/topics - ban enforcement", () => { 660 + let ctx: TestContext; 661 + let app: Hono<{ Variables: Variables }>; 662 + 663 + beforeEach(async () => { 664 + ctx = await createTestContext(); 665 + 666 + // Set up mock user for auth middleware 667 + mockUser = { 668 + did: "did:plc:test-user", 669 + handle: "testuser.test", 670 + pdsUrl: "https://test.pds", 671 + agent: { 672 + com: { 673 + atproto: { 674 + repo: { 675 + putRecord: vi.fn(async () => ({ 676 + data: { 677 + uri: "at://did:plc:test-user/space.atbb.post/3lbk7test", 678 + cid: "bafytest", 679 + }, 680 + })), 681 + }, 682 + }, 683 + }, 684 + }, 685 + }; 686 + 687 + app = new Hono<{ Variables: Variables }>(); 688 + app.route("/api/topics", createTopicsRoutes(ctx)); 689 + }); 690 + 691 + afterEach(async () => { 692 + await ctx.cleanup(); 693 + }); 694 + 695 + it("allows non-banned user to create topic", async () => { 696 + // Setup: create forum and board 697 + const [forum] = await ctx.db 698 + .select() 699 + .from(forums) 700 + .where(eq(forums.rkey, "self")) 701 + .limit(1); 702 + 703 + let category = (await ctx.db.insert(categories).values({ 704 + did: ctx.config.forumDid, 705 + rkey: "test-cat", 706 + cid: "bafycat", 707 + name: "Test Category", 708 + forumId: forum.id, 709 + createdAt: new Date(), 710 + indexedAt: new Date(), 711 + }).onConflictDoNothing().returning())[0]; 712 + 713 + if (!category) { 714 + [category] = await ctx.db 715 + .select() 716 + .from(categories) 717 + .where(eq(categories.rkey, "test-cat")) 718 + .limit(1); 719 + } 720 + 721 + await ctx.db.insert(boards).values({ 722 + did: ctx.config.forumDid, 723 + rkey: "test-board", 724 + cid: "bafyboard", 725 + name: "Test Board", 726 + categoryId: category.id, 727 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/test-cat`, 728 + createdAt: new Date(), 729 + indexedAt: new Date(), 730 + }).onConflictDoNothing(); 731 + 732 + const testBoardUri = `at://${ctx.config.forumDid}/space.atbb.forum.board/test-board`; 733 + 734 + const res = await app.request("/api/topics", { 735 + method: "POST", 736 + headers: { "Content-Type": "application/json" }, 737 + body: JSON.stringify({ 738 + text: "New topic from non-banned user", 739 + boardUri: testBoardUri, 740 + }), 741 + }); 742 + 743 + expect(res.status).toBe(201); 744 + const data = await res.json(); 745 + expect(data.uri).toBeDefined(); 746 + }); 747 + 748 + it("blocks banned user from creating topic", async () => { 749 + // Setup: create forum and board 750 + const [forum] = await ctx.db 751 + .select() 752 + .from(forums) 753 + .where(eq(forums.rkey, "self")) 754 + .limit(1); 755 + 756 + let category = (await ctx.db.insert(categories).values({ 757 + did: ctx.config.forumDid, 758 + rkey: "test-cat", 759 + cid: "bafycat", 760 + name: "Test Category", 761 + forumId: forum.id, 762 + createdAt: new Date(), 763 + indexedAt: new Date(), 764 + }).onConflictDoNothing().returning())[0]; 765 + 766 + if (!category) { 767 + [category] = await ctx.db 768 + .select() 769 + .from(categories) 770 + .where(eq(categories.rkey, "test-cat")) 771 + .limit(1); 772 + } 773 + 774 + await ctx.db.insert(boards).values({ 775 + did: ctx.config.forumDid, 776 + rkey: "test-board", 777 + cid: "bafyboard", 778 + name: "Test Board", 779 + categoryId: category.id, 780 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/test-cat`, 781 + createdAt: new Date(), 782 + indexedAt: new Date(), 783 + }).onConflictDoNothing(); 784 + 785 + const testBoardUri = `at://${ctx.config.forumDid}/space.atbb.forum.board/test-board`; 786 + 787 + // Ban the user 788 + await ctx.db.insert(modActions).values({ 789 + did: ctx.config.forumDid, 790 + rkey: "ban1", 791 + cid: "bafy...ban1", 792 + action: "space.atbb.modAction.ban", 793 + subjectDid: mockUser.did, 794 + createdBy: "did:plc:admin", 795 + createdAt: new Date(), 796 + indexedAt: new Date(), 797 + }); 798 + 799 + const res = await app.request("/api/topics", { 800 + method: "POST", 801 + headers: { "Content-Type": "application/json" }, 802 + body: JSON.stringify({ 803 + text: "Attempt from banned user", 804 + boardUri: testBoardUri, 805 + }), 806 + }); 807 + 808 + expect(res.status).toBe(403); 809 + const data = await res.json(); 810 + expect(data.error).toBe("You are banned from this forum"); 811 + }); 812 + 813 + it("returns 503 when ban check fails with database error", async () => { 814 + // Mock console.error to suppress error output 815 + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 816 + 817 + // Import helpers module and spy on getActiveBans 818 + const helpers = await import("../helpers.js"); 819 + const getActiveBansSpy = vi.spyOn(helpers, "getActiveBans"); 820 + 821 + // Make getActiveBans throw a database error 822 + getActiveBansSpy.mockRejectedValueOnce(new Error("Database connection lost")); 823 + 824 + const res = await app.request("/api/topics", { 825 + method: "POST", 826 + headers: { "Content-Type": "application/json" }, 827 + body: JSON.stringify({ 828 + text: "Topic attempt during DB error", 829 + boardUri: "at://did:plc:forum/space.atbb.forum.board/test", 830 + }), 831 + }); 832 + 833 + expect(res.status).toBe(503); 834 + const data = await res.json(); 835 + expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 836 + 837 + expect(consoleErrorSpy).toHaveBeenCalledWith( 838 + "Failed to check ban status", 839 + expect.objectContaining({ 840 + operation: "POST /api/topics - ban check", 841 + userId: mockUser.did, 842 + error: "Database connection lost", 843 + }) 844 + ); 845 + 846 + consoleErrorSpy.mockRestore(); 847 + getActiveBansSpy.mockRestore(); 848 + }); 849 + 850 + it("returns 500 when ban check fails with unexpected error (fail closed)", async () => { 851 + // Mock console.error to suppress error output 852 + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 853 + 854 + // Import helpers module and spy on getActiveBans 855 + const helpers = await import("../helpers.js"); 856 + const getActiveBansSpy = vi.spyOn(helpers, "getActiveBans"); 857 + 858 + // Make getActiveBans throw an unexpected error (not database) 859 + getActiveBansSpy.mockRejectedValueOnce(new Error("Unexpected internal error")); 860 + 861 + const res = await app.request("/api/topics", { 862 + method: "POST", 863 + headers: { "Content-Type": "application/json" }, 864 + body: JSON.stringify({ 865 + text: "Topic attempt during error", 866 + boardUri: "at://did:plc:forum/space.atbb.forum.board/test", 867 + }), 868 + }); 869 + 870 + expect(res.status).toBe(500); 871 + const data = await res.json(); 872 + expect(data.error).toBe("Unable to verify permissions. Please try again later."); 873 + 874 + expect(consoleErrorSpy).toHaveBeenCalledWith( 875 + "Failed to check ban status", 876 + expect.objectContaining({ 877 + operation: "POST /api/topics - ban check", 878 + userId: mockUser.did, 879 + error: "Unexpected internal error", 880 + }) 881 + ); 882 + 883 + consoleErrorSpy.mockRestore(); 884 + getActiveBansSpy.mockRestore(); 885 + }); 886 + 887 + it("re-throws programming errors instead of catching them", async () => { 888 + // Mock console.error to capture all error output 889 + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 890 + 891 + // Import helpers module and spy on getActiveBans 892 + const helpers = await import("../helpers.js"); 893 + const getActiveBansSpy = vi.spyOn(helpers, "getActiveBans"); 894 + 895 + // Mock getActiveBans to throw TypeError (programming bug) 896 + getActiveBansSpy.mockImplementationOnce(() => { 897 + throw new TypeError("Cannot read property 'includes' of undefined"); 898 + }); 899 + 900 + // Hono catches re-thrown errors via its internal error handler and returns 500. 901 + // We verify the CRITICAL log was emitted, proving the re-throw path was executed 902 + // (not the normal 500 path which logs "Failed to check ban status"). 903 + const res = await app.request("/api/topics", { 904 + method: "POST", 905 + headers: { "Content-Type": "application/json" }, 906 + body: JSON.stringify({ 907 + text: "Topic with programming error", 908 + boardUri: "at://did:plc:forum/space.atbb.forum.board/test", 909 + }), 910 + }); 911 + 912 + // Hono's default error handler returns 500 for uncaught throws 913 + expect(res.status).toBe(500); 914 + 915 + // Verify CRITICAL error was logged before re-throw (not "Failed to check ban status") 916 + expect(consoleErrorSpy).toHaveBeenCalledWith( 917 + "CRITICAL: Programming error in ban check", 918 + expect.objectContaining({ 919 + operation: "POST /api/topics - ban check", 920 + userId: mockUser.did, 921 + error: "Cannot read property 'includes' of undefined", 922 + stack: expect.any(String), 923 + }) 924 + ); 925 + 926 + // Verify the normal error path was NOT taken (programming errors bypass normal logging) 927 + expect(consoleErrorSpy).not.toHaveBeenCalledWith( 928 + "Failed to check ban status", 929 + expect.any(Object) 930 + ); 931 + 932 + consoleErrorSpy.mockRestore(); 933 + getActiveBansSpy.mockRestore(); 934 + }); 935 + }); 936 + 937 + describe.sequential("GET /api/topics/:id - lock/pin status", () => { 938 + let ctx: TestContext; 939 + let app: Hono; 940 + 941 + beforeEach(async () => { 942 + ctx = await createTestContext(); 943 + app = new Hono().route("/api/topics", createTopicsRoutes(ctx)); 944 + 945 + // Insert test user 946 + await ctx.db.insert(users).values({ 947 + did: "did:plc:lockpin-test-user", 948 + handle: "lockpinuser.test", 949 + indexedAt: new Date(), 950 + }).onConflictDoNothing(); 951 + }); 952 + 953 + afterEach(async () => { 954 + await ctx.cleanup(); 955 + }); 956 + 957 + it("includes locked=false and pinned=false when no mod actions", async () => { 958 + // Generate unique rkey for this test run 959 + const topicRkey = TID.nextStr(); 960 + 961 + // Insert a topic with no mod actions 962 + const [topic] = await ctx.db 963 + .insert(posts) 964 + .values({ 965 + did: "did:plc:lockpin-test-user", 966 + rkey: topicRkey, 967 + cid: `bafy${topicRkey}`, 968 + text: "Normal topic", 969 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 970 + createdAt: new Date(), 971 + indexedAt: new Date(), 972 + }) 973 + .returning(); 974 + 975 + const topicId = topic.id; 976 + 977 + // Query the topic 978 + const res = await app.request(`/api/topics/${topicId.toString()}`); 979 + 980 + expect(res.status).toBe(200); 981 + const data = await res.json(); 982 + 983 + // Should have locked=false and pinned=false 984 + expect(data.locked).toBe(false); 985 + expect(data.pinned).toBe(false); 986 + }); 987 + 988 + it("includes locked=true when topic is locked", async () => { 989 + // Generate unique rkeys 990 + const topicRkey = TID.nextStr(); 991 + const lockRkey = TID.nextStr(); 992 + 993 + // Insert a topic 994 + const [topic] = await ctx.db 995 + .insert(posts) 996 + .values({ 997 + did: "did:plc:lockpin-test-user", 998 + rkey: topicRkey, 999 + cid: `bafy${topicRkey}`, 1000 + text: "Locked topic", 1001 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 1002 + createdAt: new Date(), 1003 + indexedAt: new Date(), 1004 + }) 1005 + .returning(); 1006 + 1007 + const topicId = topic.id; 1008 + const topicUri = `at://did:plc:lockpin-test-user/space.atbb.post/${topicRkey}`; 1009 + 1010 + // Lock the topic 1011 + await ctx.db.insert(modActions).values({ 1012 + did: ctx.config.forumDid, 1013 + rkey: lockRkey, 1014 + cid: `bafy${lockRkey}`, 1015 + action: "space.atbb.modAction.lock", 1016 + subjectPostUri: topicUri, 1017 + createdBy: "did:plc:admin", 1018 + createdAt: new Date(), 1019 + indexedAt: new Date(), 1020 + }); 1021 + 1022 + // Query the topic 1023 + const res = await app.request(`/api/topics/${topicId.toString()}`); 1024 + 1025 + expect(res.status).toBe(200); 1026 + const data = await res.json(); 1027 + 1028 + // Should have locked=true and pinned=false 1029 + expect(data.locked).toBe(true); 1030 + expect(data.pinned).toBe(false); 1031 + }); 1032 + 1033 + it("includes pinned=true when topic is pinned", async () => { 1034 + // Generate unique rkeys 1035 + const topicRkey = TID.nextStr(); 1036 + const pinRkey = TID.nextStr(); 1037 + 1038 + // Insert a topic 1039 + const [topic] = await ctx.db 1040 + .insert(posts) 1041 + .values({ 1042 + did: "did:plc:lockpin-test-user", 1043 + rkey: topicRkey, 1044 + cid: `bafy${topicRkey}`, 1045 + text: "Pinned topic", 1046 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 1047 + createdAt: new Date(), 1048 + indexedAt: new Date(), 1049 + }) 1050 + .returning(); 1051 + 1052 + const topicId = topic.id; 1053 + const topicUri = `at://did:plc:lockpin-test-user/space.atbb.post/${topicRkey}`; 1054 + 1055 + // Pin the topic 1056 + await ctx.db.insert(modActions).values({ 1057 + did: ctx.config.forumDid, 1058 + rkey: pinRkey, 1059 + cid: `bafy${pinRkey}`, 1060 + action: "space.atbb.modAction.pin", 1061 + subjectPostUri: topicUri, 1062 + createdBy: "did:plc:admin", 1063 + createdAt: new Date(), 1064 + indexedAt: new Date(), 1065 + }); 1066 + 1067 + // Query the topic 1068 + const res = await app.request(`/api/topics/${topicId.toString()}`); 1069 + 1070 + expect(res.status).toBe(200); 1071 + const data = await res.json(); 1072 + 1073 + // Should have locked=false and pinned=true 1074 + expect(data.locked).toBe(false); 1075 + expect(data.pinned).toBe(true); 1076 + }); 1077 + 1078 + it("includes locked=true and pinned=true when topic has both", async () => { 1079 + // Generate unique rkeys 1080 + const topicRkey = TID.nextStr(); 1081 + const pinRkey = TID.nextStr(); 1082 + const lockRkey = TID.nextStr(); 1083 + 1084 + // Insert a topic 1085 + const [topic] = await ctx.db 1086 + .insert(posts) 1087 + .values({ 1088 + did: "did:plc:lockpin-test-user", 1089 + rkey: topicRkey, 1090 + cid: `bafy${topicRkey}`, 1091 + text: "Locked and pinned topic", 1092 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 1093 + createdAt: new Date(), 1094 + indexedAt: new Date(), 1095 + }) 1096 + .returning(); 1097 + 1098 + const topicId = topic.id; 1099 + const topicUri = `at://did:plc:lockpin-test-user/space.atbb.post/${topicRkey}`; 1100 + 1101 + // Pin the topic (action 1) 1102 + await ctx.db.insert(modActions).values({ 1103 + did: ctx.config.forumDid, 1104 + rkey: pinRkey, 1105 + cid: `bafy${pinRkey}`, 1106 + action: "space.atbb.modAction.pin", 1107 + subjectPostUri: topicUri, 1108 + createdBy: "did:plc:admin", 1109 + createdAt: new Date(Date.now() - 1000), // 1 second earlier 1110 + indexedAt: new Date(Date.now() - 1000), 1111 + }); 1112 + 1113 + // Lock the topic (action 2, more recent) 1114 + await ctx.db.insert(modActions).values({ 1115 + did: ctx.config.forumDid, 1116 + rkey: lockRkey, 1117 + cid: `bafy${lockRkey}`, 1118 + action: "space.atbb.modAction.lock", 1119 + subjectPostUri: topicUri, 1120 + createdBy: "did:plc:admin", 1121 + createdAt: new Date(), 1122 + indexedAt: new Date(), 1123 + }); 1124 + 1125 + // Query the topic 1126 + const res = await app.request(`/api/topics/${topicId.toString()}`); 1127 + 1128 + expect(res.status).toBe(200); 1129 + const data = await res.json(); 1130 + 1131 + // Should have BOTH locked=true AND pinned=true 1132 + expect(data.locked).toBe(true); 1133 + expect(data.pinned).toBe(true); 1134 + }); 1135 + }); 1136 + 1137 + describe.sequential("GET /api/topics/:id - hidden post filtering", () => { 1138 + let ctx: TestContext; 1139 + let app: Hono; 1140 + 1141 + beforeEach(async () => { 1142 + ctx = await createTestContext(); 1143 + app = new Hono().route("/api/topics", createTopicsRoutes(ctx)); 1144 + 1145 + await ctx.db.insert(users).values([ 1146 + { did: "did:plc:hidden-test-user1", handle: "hiddenuser1.test", indexedAt: new Date() }, 1147 + { did: "did:plc:hidden-test-user2", handle: "hiddenuser2.test", indexedAt: new Date() }, 1148 + ]).onConflictDoNothing(); 1149 + }); 1150 + 1151 + afterEach(async () => { 1152 + await ctx.cleanup(); 1153 + }); 1154 + 1155 + it("excludes hidden (mod-deleted) posts from replies", async () => { 1156 + const topicRkey = TID.nextStr(); 1157 + const visibleRkey = TID.nextStr(); 1158 + const hiddenRkey = TID.nextStr(); 1159 + 1160 + const [topic] = await ctx.db.insert(posts).values({ 1161 + did: "did:plc:hidden-test-user1", 1162 + rkey: topicRkey, 1163 + cid: `bafy${topicRkey}`, 1164 + text: "Topic post", 1165 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 1166 + createdAt: new Date(), 1167 + indexedAt: new Date(), 1168 + }).returning(); 1169 + 1170 + const topicId = topic.id; 1171 + 1172 + await ctx.db.insert(posts).values([ 1173 + { 1174 + did: "did:plc:hidden-test-user1", 1175 + rkey: visibleRkey, 1176 + cid: `bafy${visibleRkey}`, 1177 + text: "Visible reply", 1178 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 1179 + rootPostId: topicId, 1180 + parentPostId: topicId, 1181 + createdAt: new Date(), 1182 + indexedAt: new Date(), 1183 + }, 1184 + { 1185 + did: "did:plc:hidden-test-user2", 1186 + rkey: hiddenRkey, 1187 + cid: `bafy${hiddenRkey}`, 1188 + text: "Hidden reply", 1189 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 1190 + rootPostId: topicId, 1191 + parentPostId: topicId, 1192 + createdAt: new Date(), 1193 + indexedAt: new Date(), 1194 + }, 1195 + ]); 1196 + 1197 + const hiddenPostUri = `at://did:plc:hidden-test-user2/space.atbb.post/${hiddenRkey}`; 1198 + const hideActionRkey = TID.nextStr(); 1199 + 1200 + // Hide one reply via mod action 1201 + await ctx.db.insert(modActions).values({ 1202 + did: ctx.config.forumDid, 1203 + rkey: hideActionRkey, 1204 + cid: `bafy${hideActionRkey}`, 1205 + action: "space.atbb.modAction.delete", 1206 + subjectPostUri: hiddenPostUri, 1207 + createdBy: "did:plc:admin", 1208 + createdAt: new Date(), 1209 + indexedAt: new Date(), 1210 + }); 1211 + 1212 + const res = await app.request(`/api/topics/${topicId.toString()}`); 1213 + 1214 + expect(res.status).toBe(200); 1215 + const data = await res.json(); 1216 + 1217 + // Should only include the visible reply 1218 + expect(data.replies).toHaveLength(1); 1219 + expect(data.replies[0].text).toBe("Visible reply"); 1220 + }); 1221 + }); 1222 + 1223 + describe.sequential("GET /api/topics/:id - fail-open error handling", () => { 1224 + let ctx: TestContext; 1225 + let app: Hono; 1226 + 1227 + beforeEach(async () => { 1228 + ctx = await createTestContext(); 1229 + app = new Hono().route("/api/topics", createTopicsRoutes(ctx)); 1230 + 1231 + await ctx.db.insert(users).values({ 1232 + did: "did:plc:failopen-test-user", 1233 + handle: "failopenuser.test", 1234 + indexedAt: new Date(), 1235 + }).onConflictDoNothing(); 1236 + }); 1237 + 1238 + afterEach(async () => { 1239 + await ctx.cleanup(); 1240 + }); 1241 + 1242 + it("returns 200 with all replies when ban lookup fails (fail-open)", async () => { 1243 + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 1244 + 1245 + const topicRkey = TID.nextStr(); 1246 + const replyRkey = TID.nextStr(); 1247 + 1248 + const [topic] = await ctx.db.insert(posts).values({ 1249 + did: "did:plc:failopen-test-user", 1250 + rkey: topicRkey, 1251 + cid: `bafy${topicRkey}`, 1252 + text: "Topic post", 1253 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 1254 + createdAt: new Date(), 1255 + indexedAt: new Date(), 1256 + }).returning(); 1257 + 1258 + await ctx.db.insert(posts).values({ 1259 + did: "did:plc:failopen-test-user", 1260 + rkey: replyRkey, 1261 + cid: `bafy${replyRkey}`, 1262 + text: "Reply", 1263 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 1264 + rootPostId: topic.id, 1265 + parentPostId: topic.id, 1266 + createdAt: new Date(), 1267 + indexedAt: new Date(), 1268 + }); 1269 + 1270 + const helpers = await import("../helpers.js"); 1271 + const getActiveBansSpy = vi.spyOn(helpers, "getActiveBans"); 1272 + getActiveBansSpy.mockRejectedValueOnce(new Error("Database connection lost")); 1273 + 1274 + const res = await app.request(`/api/topics/${topic.id.toString()}`); 1275 + 1276 + // Should still return 200 - read path fails open 1277 + expect(res.status).toBe(200); 1278 + const data = await res.json(); 1279 + 1280 + // Reply is still visible even if ban lookup failed 1281 + expect(data.replies).toHaveLength(1); 1282 + 1283 + // Verify error was logged 1284 + expect(consoleErrorSpy).toHaveBeenCalledWith( 1285 + expect.stringContaining("Failed to query bans"), 1286 + expect.objectContaining({ operation: "GET /api/topics/:id - ban check" }) 1287 + ); 1288 + 1289 + consoleErrorSpy.mockRestore(); 1290 + getActiveBansSpy.mockRestore(); 1291 + }); 1292 + });
+242 -2
apps/appview/src/routes/helpers.ts
··· 1 - import { users, forums, posts, categories, boards } from "@atbb/db"; 1 + import { users, forums, posts, categories, boards, modActions } from "@atbb/db"; 2 2 import type { Database } from "@atbb/db"; 3 - import { eq, and, inArray } from "drizzle-orm"; 3 + import { eq, and, inArray, desc } from "drizzle-orm"; 4 4 import { UnicodeString } from "@atproto/api"; 5 5 import { parseAtUri } from "../lib/at-uri.js"; 6 6 ··· 351 351 indexedAt: serializeDate(board.indexedAt), 352 352 }; 353 353 } 354 + 355 + /** 356 + * Query active bans for a list of user DIDs. 357 + * A user is banned if their most recent modAction is "ban" (not "unban"). 358 + * 359 + * @param db Database instance 360 + * @param dids Array of user DIDs to check 361 + * @returns Set of banned DIDs (subset of input) 362 + */ 363 + export async function getActiveBans( 364 + db: Database, 365 + dids: string[] 366 + ): Promise<Set<string>> { 367 + if (dids.length === 0) { 368 + return new Set(); 369 + } 370 + 371 + try { 372 + // Query ban/unban actions for these DIDs only (not other action types like mute) 373 + // We need the most recent ban/unban action per DID to determine current state 374 + const actions = await db 375 + .select({ 376 + subjectDid: modActions.subjectDid, 377 + action: modActions.action, 378 + createdAt: modActions.createdAt, 379 + }) 380 + .from(modActions) 381 + .where( 382 + and( 383 + inArray(modActions.subjectDid, dids), 384 + inArray(modActions.action, [ 385 + "space.atbb.modAction.ban", 386 + "space.atbb.modAction.unban", 387 + ]) 388 + ) 389 + ) 390 + .orderBy(desc(modActions.createdAt)) 391 + .limit(dids.length * 100); // Defensive limit: at most 100 actions per user 392 + 393 + // Group by subjectDid and take most recent ban/unban action 394 + const mostRecentByDid = new Map<string, string>(); 395 + for (const row of actions) { 396 + if (row.subjectDid && !mostRecentByDid.has(row.subjectDid)) { 397 + mostRecentByDid.set(row.subjectDid, row.action); 398 + } 399 + } 400 + 401 + // A user is banned if most recent ban/unban action is "ban" 402 + const banned = new Set<string>(); 403 + for (const [did, action] of mostRecentByDid) { 404 + if (action === "space.atbb.modAction.ban") { 405 + banned.add(did); 406 + } 407 + } 408 + 409 + return banned; 410 + } catch (error) { 411 + console.error("Failed to query active bans", { 412 + operation: "getActiveBans", 413 + didCount: dids.length, 414 + error: error instanceof Error ? error.message : String(error), 415 + }); 416 + throw error; // Let caller decide fail policy 417 + } 418 + } 419 + 420 + /** 421 + * Query moderation status for a topic (lock/pin). 422 + * 423 + * @param db Database instance 424 + * @param topicId Internal post ID of the topic (root post) 425 + * @returns { locked: boolean, pinned: boolean } 426 + */ 427 + export async function getTopicModStatus( 428 + db: Database, 429 + topicId: bigint 430 + ): Promise<{ locked: boolean; pinned: boolean }> { 431 + try { 432 + // Look up the topic to get its AT-URI 433 + const [topic] = await db 434 + .select({ 435 + did: posts.did, 436 + rkey: posts.rkey, 437 + }) 438 + .from(posts) 439 + .where(eq(posts.id, topicId)) 440 + .limit(1); 441 + 442 + if (!topic) { 443 + return { locked: false, pinned: false }; 444 + } 445 + 446 + const topicUri = `at://${topic.did}/space.atbb.post/${topic.rkey}`; 447 + 448 + // Query only lock/unlock/pin/unpin actions for this topic URI 449 + const actions = await db 450 + .select({ 451 + action: modActions.action, 452 + createdAt: modActions.createdAt, 453 + }) 454 + .from(modActions) 455 + .where( 456 + and( 457 + eq(modActions.subjectPostUri, topicUri), 458 + inArray(modActions.action, [ 459 + "space.atbb.modAction.lock", 460 + "space.atbb.modAction.unlock", 461 + "space.atbb.modAction.pin", 462 + "space.atbb.modAction.unpin", 463 + ]) 464 + ) 465 + ) 466 + .orderBy(desc(modActions.createdAt)) 467 + .limit(100); 468 + 469 + if (actions.length === 0) { 470 + return { locked: false, pinned: false }; 471 + } 472 + 473 + // Lock and pin are independent states - check most recent action for each 474 + // Find most recent lock/unlock action 475 + const mostRecentLockAction = actions.find( 476 + (a) => 477 + a.action === "space.atbb.modAction.lock" || 478 + a.action === "space.atbb.modAction.unlock" 479 + ); 480 + 481 + // Find most recent pin/unpin action 482 + const mostRecentPinAction = actions.find( 483 + (a) => 484 + a.action === "space.atbb.modAction.pin" || 485 + a.action === "space.atbb.modAction.unpin" 486 + ); 487 + 488 + return { 489 + locked: 490 + mostRecentLockAction?.action === "space.atbb.modAction.lock" || false, 491 + pinned: 492 + mostRecentPinAction?.action === "space.atbb.modAction.pin" || false, 493 + }; 494 + } catch (error) { 495 + console.error("Failed to query topic moderation status", { 496 + operation: "getTopicModStatus", 497 + topicId: topicId.toString(), 498 + error: error instanceof Error ? error.message : String(error), 499 + }); 500 + throw error; // Let caller decide fail policy 501 + } 502 + } 503 + 504 + /** 505 + * Query which posts in a list are currently hidden by moderator action. 506 + * A post is hidden if its most recent modAction is "delete" (not "undelete"). 507 + * 508 + * @param db Database instance 509 + * @param postIds Array of post IDs to check 510 + * @returns Set of hidden post IDs (subset of input) 511 + */ 512 + export async function getHiddenPosts( 513 + db: Database, 514 + postIds: bigint[] 515 + ): Promise<Set<bigint>> { 516 + if (postIds.length === 0) { 517 + return new Set(); 518 + } 519 + 520 + try { 521 + // Look up URIs for these post IDs 522 + const postRecords = await db 523 + .select({ 524 + id: posts.id, 525 + did: posts.did, 526 + rkey: posts.rkey, 527 + }) 528 + .from(posts) 529 + .where(inArray(posts.id, postIds)) 530 + .limit(1000); // Prevent memory exhaustion 531 + 532 + if (postRecords.length === 0) { 533 + return new Set(); 534 + } 535 + 536 + // Build URI->ID mapping 537 + const uriToId = new Map<string, bigint>(); 538 + const uris: string[] = []; 539 + for (const post of postRecords) { 540 + const uri = `at://${post.did}/space.atbb.post/${post.rkey}`; 541 + uriToId.set(uri, post.id); 542 + uris.push(uri); 543 + } 544 + 545 + // Query only delete/undelete actions for these URIs 546 + const actions = await db 547 + .select({ 548 + subjectPostUri: modActions.subjectPostUri, 549 + action: modActions.action, 550 + createdAt: modActions.createdAt, 551 + }) 552 + .from(modActions) 553 + .where( 554 + and( 555 + inArray(modActions.subjectPostUri, uris), 556 + inArray(modActions.action, [ 557 + "space.atbb.modAction.delete", 558 + "space.atbb.modAction.undelete", 559 + ]) 560 + ) 561 + ) 562 + .orderBy(desc(modActions.createdAt)) 563 + .limit(uris.length * 10); // At most 10 delete/undelete actions per post 564 + 565 + // Group by URI and take most recent 566 + const mostRecentByUri = new Map<string, string>(); 567 + for (const row of actions) { 568 + if (row.subjectPostUri && !mostRecentByUri.has(row.subjectPostUri)) { 569 + mostRecentByUri.set(row.subjectPostUri, row.action); 570 + } 571 + } 572 + 573 + // A post is hidden if most recent delete/undelete action is "delete" 574 + const hidden = new Set<bigint>(); 575 + for (const [uri, action] of mostRecentByUri) { 576 + if (action === "space.atbb.modAction.delete") { 577 + const postId = uriToId.get(uri); 578 + if (postId !== undefined) { 579 + hidden.add(postId); 580 + } 581 + } 582 + } 583 + 584 + return hidden; 585 + } catch (error) { 586 + console.error("Failed to query hidden posts", { 587 + operation: "getHiddenPosts", 588 + postIdCount: postIds.length, 589 + error: error instanceof Error ? error.message : String(error), 590 + }); 591 + throw error; // Let caller decide fail policy 592 + } 593 + }
+88 -1
apps/appview/src/routes/posts.ts
··· 4 4 import type { Variables } from "../types.js"; 5 5 import { requireAuth } from "../middleware/auth.js"; 6 6 import { requirePermission } from "../middleware/permissions.js"; 7 - import { isProgrammingError, isNetworkError } from "../lib/errors.js"; 7 + import { isProgrammingError, isNetworkError, isDatabaseError } from "../lib/errors.js"; 8 8 import { 9 9 validatePostText, 10 10 parseBigIntParam, 11 11 getPostsByIds, 12 12 validateReplyParent, 13 + getActiveBans, 14 + getTopicModStatus, 13 15 } from "./helpers.js"; 14 16 15 17 export function createPostsRoutes(ctx: AppContext) { ··· 45 47 ); 46 48 } 47 49 50 + // Check if user is banned before processing request 51 + try { 52 + const bannedUsers = await getActiveBans(ctx.db, [user.did]); 53 + if (bannedUsers.has(user.did)) { 54 + return c.json({ error: "You are banned from this forum" }, 403); 55 + } 56 + } catch (error) { 57 + // Re-throw programming errors (code bugs) - don't hide them 58 + if (isProgrammingError(error)) { 59 + console.error("CRITICAL: Programming error in ban check", { 60 + operation: "POST /api/posts - ban check", 61 + userId: user.did, 62 + error: error instanceof Error ? error.message : String(error), 63 + stack: error instanceof Error ? error.stack : undefined, 64 + }); 65 + throw error; // Let global error handler catch it 66 + } 67 + 68 + console.error("Failed to check ban status", { 69 + operation: "POST /api/posts - ban check", 70 + userId: user.did, 71 + error: error instanceof Error ? error.message : String(error), 72 + }); 73 + 74 + // Database connection errors - temporary, user should retry 75 + if (error instanceof Error && isDatabaseError(error)) { 76 + return c.json( 77 + { error: "Database temporarily unavailable. Please try again later." }, 78 + 503 79 + ); 80 + } 81 + 82 + // Unexpected errors - fail closed 83 + return c.json( 84 + { error: "Unable to verify permissions. Please try again later." }, 85 + 500 86 + ); 87 + } 88 + 89 + // Check if topic is locked before processing request 90 + try { 91 + const modStatus = await getTopicModStatus(ctx.db, rootId); 92 + if (modStatus.locked) { 93 + return c.json({ error: "This topic is locked and not accepting new replies" }, 403); 94 + } 95 + } catch (error) { 96 + if (isProgrammingError(error)) { 97 + console.error("CRITICAL: Programming error in lock check", { 98 + operation: "POST /api/posts - lock check", 99 + userId: user.did, 100 + rootId: rootIdStr, 101 + error: error instanceof Error ? error.message : String(error), 102 + stack: error instanceof Error ? error.stack : undefined, 103 + }); 104 + throw error; 105 + } 106 + 107 + console.error("Failed to check topic lock status", { 108 + operation: "POST /api/posts - lock check", 109 + userId: user.did, 110 + rootId: rootIdStr, 111 + error: error instanceof Error ? error.message : String(error), 112 + }); 113 + 114 + if (error instanceof Error && isDatabaseError(error)) { 115 + return c.json( 116 + { error: "Database temporarily unavailable. Please try again later." }, 117 + 503 118 + ); 119 + } 120 + 121 + // Fail closed: if we can't verify lock status, deny the write 122 + return c.json( 123 + { error: "Unable to verify topic status. Please try again later." }, 124 + 500 125 + ); 126 + } 127 + 48 128 try { 49 129 // Look up root and parent posts 50 130 const postsMap = await getPostsByIds(ctx.db, [rootId, parentId]); ··· 133 213 { 134 214 error: "Unable to reach your PDS. Please try again later.", 135 215 }, 216 + 503 217 + ); 218 + } 219 + 220 + if (error instanceof Error && isDatabaseError(error)) { 221 + return c.json( 222 + { error: "Database temporarily unavailable. Please try again later." }, 136 223 503 137 224 ); 138 225 }
+91 -1
apps/appview/src/routes/topics.ts
··· 14 14 validatePostText, 15 15 getForumByUri, 16 16 getBoardByUri, 17 + getActiveBans, 18 + getHiddenPosts, 19 + getTopicModStatus, 17 20 } from "./helpers.js"; 18 21 19 22 /** ··· 58 61 .orderBy(asc(posts.createdAt)) 59 62 .limit(1000); // Defensive limit, consistent with categories 60 63 64 + // Get banned users - fail open (show content if ban lookup fails) 65 + const allUserDids = [ 66 + topicResult.post.did, 67 + ...replyResults.map((r) => r.post.did), 68 + ]; 69 + let bannedUsers = new Set<string>(); 70 + try { 71 + bannedUsers = await getActiveBans(ctx.db, allUserDids); 72 + } catch (error) { 73 + console.error("Failed to query bans for topic view - showing all replies", { 74 + operation: "GET /api/topics/:id - ban check", 75 + topicId: id, 76 + error: error instanceof Error ? error.message : String(error), 77 + }); 78 + } 79 + 80 + // Get hidden posts - fail open (show content if hide lookup fails) 81 + const allPostIds = replyResults.map((r) => r.post.id); 82 + let hiddenPosts = new Set<bigint>(); 83 + try { 84 + hiddenPosts = await getHiddenPosts(ctx.db, allPostIds); 85 + } catch (error) { 86 + console.error("Failed to query hidden posts for topic view - showing all replies", { 87 + operation: "GET /api/topics/:id - hidden posts", 88 + topicId: id, 89 + error: error instanceof Error ? error.message : String(error), 90 + }); 91 + } 92 + 93 + // Filter replies - exclude banned users and hidden posts 94 + const filteredReplies = replyResults.filter( 95 + ({ post }) => !bannedUsers.has(post.did) && !hiddenPosts.has(post.id) 96 + ); 97 + 98 + // Get lock/pin status - fail open (default unlocked/unpinned if lookup fails) 99 + let modStatus = { locked: false, pinned: false }; 100 + try { 101 + modStatus = await getTopicModStatus(ctx.db, topicId); 102 + } catch (error) { 103 + console.error("Failed to query topic mod status - showing as unlocked", { 104 + operation: "GET /api/topics/:id - mod status", 105 + topicId: id, 106 + error: error instanceof Error ? error.message : String(error), 107 + }); 108 + } 109 + 61 110 const { post: topicPost, author: topicAuthor } = topicResult; 62 111 63 112 return c.json({ 64 113 topicId: id, 114 + locked: modStatus.locked, 115 + pinned: modStatus.pinned, 65 116 post: serializePost(topicPost, topicAuthor), 66 - replies: replyResults.map(({ post, author }) => 117 + replies: filteredReplies.map(({ post, author }) => 67 118 serializePost(post, author) 68 119 ), 69 120 }); ··· 85 136 .post("/", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.createTopics"), async (c) => { 86 137 // user is guaranteed to exist after requireAuth and requirePermission middleware 87 138 const user = c.get("user")!; 139 + 140 + // Check if user is banned before processing request 141 + try { 142 + const bannedUsers = await getActiveBans(ctx.db, [user.did]); 143 + if (bannedUsers.has(user.did)) { 144 + return c.json({ error: "You are banned from this forum" }, 403); 145 + } 146 + } catch (error) { 147 + // Re-throw programming errors (code bugs) - don't hide them 148 + if (isProgrammingError(error)) { 149 + console.error("CRITICAL: Programming error in ban check", { 150 + operation: "POST /api/topics - ban check", 151 + userId: user.did, 152 + error: error instanceof Error ? error.message : String(error), 153 + stack: error instanceof Error ? error.stack : undefined, 154 + }); 155 + throw error; // Let global error handler catch it 156 + } 157 + 158 + console.error("Failed to check ban status", { 159 + operation: "POST /api/topics - ban check", 160 + userId: user.did, 161 + error: error instanceof Error ? error.message : String(error), 162 + }); 163 + 164 + // Database connection errors - temporary, user should retry 165 + if (error instanceof Error && isDatabaseError(error)) { 166 + return c.json( 167 + { error: "Database temporarily unavailable. Please try again later." }, 168 + 503 169 + ); 170 + } 171 + 172 + // Unexpected errors - fail closed 173 + return c.json( 174 + { error: "Unable to verify permissions. Please try again later." }, 175 + 500 176 + ); 177 + } 88 178 89 179 // Parse and validate request body 90 180 let body: any;
+3 -1
bruno/AppView API/Posts/Create Reply.bru
··· 53 53 54 54 Returns 400 for invalid input or validation failures. 55 55 Returns 401 if not authenticated. 56 + Returns 403 if the user is banned from the forum. 56 57 Returns 404 if root or parent post not found. 57 - Returns 503 if PDS unreachable (network error). 58 + Returns 403 if the topic is locked (no new replies allowed). 59 + Returns 503 if PDS or database is temporarily unavailable (retry later). 58 60 Returns 500 for server errors. 59 61 }
+2 -1
bruno/AppView API/Topics/Create Topic.bru
··· 48 48 Error codes: 49 49 - 400: Invalid input (missing text, invalid boardUri, malformed JSON) 50 50 - 401: Unauthorized (not authenticated) 51 + - 403: Forbidden (user is banned from this forum) 51 52 - 404: Board not found 52 - - 503: Unable to reach PDS (network error, retry later) 53 + - 503: Unable to reach PDS or database (temporary, retry later) 53 54 - 500: Server error 54 55 }
+10
bruno/AppView API/Topics/Get Topic.bru
··· 17 17 res.body.topicId: isDefined 18 18 res.body.post: isDefined 19 19 res.body.replies: isArray 20 + res.body.locked: isDefined 21 + res.body.pinned: isDefined 20 22 } 21 23 22 24 docs { ··· 28 30 Returns: 29 31 { 30 32 "topicId": "1", 33 + "locked": false, 34 + "pinned": false, 31 35 "post": { 32 36 "id": "1", 33 37 "did": "did:plc:...", ··· 56 60 ] 57 61 } 58 62 63 + Moderation enforcement (fail-open: errors in mod lookups show all content): 64 + - Replies from banned users are excluded 65 + - Replies hidden by mod "delete" action are excluded (restored by "undelete") 66 + - locked/pinned reflect most recent lock/pin action per topic 67 + 59 68 Returns 400 if ID is invalid. 60 69 Returns 404 if topic not found. 70 + Returns 500 if topic query fails. 61 71 Maximum 1000 replies returned (defensive limit). 62 72 }
+1652
docs/plans/2026-02-16-enforce-mod-actions-read-path.md
··· 1 + # Enforce Mod Actions in Read-Path API Responses 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Make the API respect moderation actions - banned users' posts hidden, locked topics read-only, hidden posts excluded from responses. 6 + 7 + **Architecture:** Add helper functions to query active mod actions, then integrate checks into existing read endpoints (GET /api/topics/:id) and write endpoints (POST /api/topics, POST /api/posts). Use efficient database queries with proper indexing to minimize performance impact (<5ms overhead). 8 + 9 + **Tech Stack:** Drizzle ORM, PostgreSQL, Hono (TypeScript), Vitest 10 + 11 + --- 12 + 13 + ## Task 1: Add helper to query active bans for multiple users 14 + 15 + **Files:** 16 + - Modify: `apps/appview/src/routes/helpers.ts` (add function after `getBoardByUri`) 17 + - Test: `apps/appview/src/routes/__tests__/helpers.test.ts` 18 + 19 + **Step 1: Write the failing test** 20 + 21 + Add to `apps/appview/src/routes/__tests__/helpers.test.ts`: 22 + 23 + ```typescript 24 + describe("getActiveBans", () => { 25 + it("returns empty set when no bans exist", async () => { 26 + const ctx = await createTestContext(); 27 + const banned = await getActiveBans(ctx.db, ["did:plc:user1", "did:plc:user2"]); 28 + 29 + expect(banned.size).toBe(0); 30 + }); 31 + 32 + it("returns banned users from active ban actions", async () => { 33 + const ctx = await createTestContext(); 34 + 35 + // Insert a ban action 36 + await ctx.db.insert(modActions).values({ 37 + did: ctx.config.forumDid, 38 + rkey: "ban1", 39 + cid: "bafy...1", 40 + action: "space.atbb.modAction.action#ban", 41 + subjectDid: "did:plc:banned-user", 42 + createdBy: "did:plc:admin", 43 + createdAt: new Date(), 44 + indexedAt: new Date(), 45 + }); 46 + 47 + const banned = await getActiveBans(ctx.db, [ 48 + "did:plc:banned-user", 49 + "did:plc:normal-user" 50 + ]); 51 + 52 + expect(banned.size).toBe(1); 53 + expect(banned.has("did:plc:banned-user")).toBe(true); 54 + expect(banned.has("did:plc:normal-user")).toBe(false); 55 + }); 56 + 57 + it("excludes users with reversed bans (unban action)", async () => { 58 + const ctx = await createTestContext(); 59 + 60 + // Ban then unban 61 + await ctx.db.insert(modActions).values([ 62 + { 63 + did: ctx.config.forumDid, 64 + rkey: "ban1", 65 + cid: "bafy...1", 66 + action: "space.atbb.modAction.action#ban", 67 + subjectDid: "did:plc:user1", 68 + createdBy: "did:plc:admin", 69 + createdAt: new Date("2026-01-01"), 70 + indexedAt: new Date("2026-01-01"), 71 + }, 72 + { 73 + did: ctx.config.forumDid, 74 + rkey: "ban2", 75 + cid: "bafy...2", 76 + action: "space.atbb.modAction.action#unban", 77 + subjectDid: "did:plc:user1", 78 + createdBy: "did:plc:admin", 79 + createdAt: new Date("2026-01-02"), 80 + indexedAt: new Date("2026-01-02"), 81 + } 82 + ]); 83 + 84 + const banned = await getActiveBans(ctx.db, ["did:plc:user1"]); 85 + 86 + expect(banned.size).toBe(0); 87 + }); 88 + 89 + it("returns empty set for empty input array", async () => { 90 + const ctx = await createTestContext(); 91 + const banned = await getActiveBans(ctx.db, []); 92 + 93 + expect(banned.size).toBe(0); 94 + }); 95 + }); 96 + ``` 97 + 98 + **Step 2: Run test to verify it fails** 99 + 100 + ```bash 101 + pnpm --filter @atbb/appview test src/routes/__tests__/helpers.test.ts 102 + ``` 103 + 104 + Expected: FAIL with "getActiveBans is not defined" 105 + 106 + **Step 3: Write minimal implementation** 107 + 108 + Add to `apps/appview/src/routes/helpers.ts`: 109 + 110 + ```typescript 111 + import { modActions } from "@atbb/db"; 112 + import { inArray, desc, eq } from "drizzle-orm"; 113 + 114 + /** 115 + * Query active bans for a list of user DIDs. 116 + * A user is banned if their most recent modAction is "ban" (not "unban"). 117 + * 118 + * @param db Database instance 119 + * @param dids Array of user DIDs to check 120 + * @returns Set of banned DIDs (subset of input) 121 + */ 122 + export async function getActiveBans( 123 + db: Database, 124 + dids: string[] 125 + ): Promise<Set<string>> { 126 + if (dids.length === 0) { 127 + return new Set(); 128 + } 129 + 130 + // Query all mod actions for these DIDs, grouped by subjectDid 131 + // We need the most recent action per DID to determine current state 132 + const actions = await db 133 + .select({ 134 + subjectDid: modActions.subjectDid, 135 + action: modActions.action, 136 + createdAt: modActions.createdAt, 137 + }) 138 + .from(modActions) 139 + .where(inArray(modActions.subjectDid, dids)) 140 + .orderBy(desc(modActions.createdAt)); 141 + 142 + // Group by subjectDid and take most recent 143 + const mostRecentByDid = new Map<string, string>(); 144 + for (const row of actions) { 145 + if (row.subjectDid && !mostRecentByDid.has(row.subjectDid)) { 146 + mostRecentByDid.set(row.subjectDid, row.action); 147 + } 148 + } 149 + 150 + // A user is banned if most recent action is "ban" 151 + const banned = new Set<string>(); 152 + for (const [did, action] of mostRecentByDid) { 153 + if (action === "space.atbb.modAction.action#ban") { 154 + banned.add(did); 155 + } 156 + } 157 + 158 + return banned; 159 + } 160 + ``` 161 + 162 + Update exports in `apps/appview/src/routes/helpers.ts` - add `getActiveBans` to the imports in `helpers.test.ts` and other files that will use it. 163 + 164 + **Step 4: Run test to verify it passes** 165 + 166 + ```bash 167 + pnpm --filter @atbb/appview test src/routes/__tests__/helpers.test.ts 168 + ``` 169 + 170 + Expected: PASS (4 new tests passing) 171 + 172 + **Step 5: Commit** 173 + 174 + ```bash 175 + git add apps/appview/src/routes/helpers.ts apps/appview/src/routes/__tests__/helpers.test.ts 176 + git commit -m "feat(appview): add getActiveBans helper for filtering banned users 177 + 178 + - Query active ban status for multiple users in one query 179 + - Returns Set of currently banned DIDs 180 + - Handles ban/unban reversals correctly 181 + - Includes comprehensive test coverage (4 tests)" 182 + ``` 183 + 184 + --- 185 + 186 + ## Task 2: Add helper to query topic lock/pin status 187 + 188 + **Files:** 189 + - Modify: `apps/appview/src/routes/helpers.ts` (add function after `getActiveBans`) 190 + - Test: `apps/appview/src/routes/__tests__/helpers.test.ts` 191 + 192 + **Step 1: Write the failing test** 193 + 194 + Add to `apps/appview/src/routes/__tests__/helpers.test.ts`: 195 + 196 + ```typescript 197 + describe("getTopicModStatus", () => { 198 + it("returns unlocked/unpinned when no mod actions exist", async () => { 199 + const ctx = await createTestContext(); 200 + const status = await getTopicModStatus(ctx.db, BigInt(999)); 201 + 202 + expect(status.locked).toBe(false); 203 + expect(status.pinned).toBe(false); 204 + }); 205 + 206 + it("returns locked=true when most recent action is lock", async () => { 207 + const ctx = await createTestContext(); 208 + 209 + // Create a post 210 + await ctx.db.insert(posts).values({ 211 + id: BigInt(1), 212 + did: "did:plc:user1", 213 + rkey: "post1", 214 + cid: "bafy...1", 215 + text: "Topic text", 216 + forumUri: "at://forum/space.atbb.forum.forum/self", 217 + createdAt: new Date(), 218 + indexedAt: new Date(), 219 + }); 220 + 221 + const postUri = "at://did:plc:user1/space.atbb.post/post1"; 222 + 223 + // Lock the topic 224 + await ctx.db.insert(modActions).values({ 225 + did: ctx.config.forumDid, 226 + rkey: "lock1", 227 + cid: "bafy...lock", 228 + action: "space.atbb.modAction.action#lock", 229 + subjectPostUri: postUri, 230 + createdBy: "did:plc:mod", 231 + createdAt: new Date(), 232 + indexedAt: new Date(), 233 + }); 234 + 235 + const status = await getTopicModStatus(ctx.db, BigInt(1)); 236 + 237 + expect(status.locked).toBe(true); 238 + expect(status.pinned).toBe(false); 239 + }); 240 + 241 + it("returns pinned=true when most recent action is pin", async () => { 242 + const ctx = await createTestContext(); 243 + 244 + await ctx.db.insert(posts).values({ 245 + id: BigInt(2), 246 + did: "did:plc:user2", 247 + rkey: "post2", 248 + cid: "bafy...2", 249 + text: "Pinned topic", 250 + forumUri: "at://forum/space.atbb.forum.forum/self", 251 + createdAt: new Date(), 252 + indexedAt: new Date(), 253 + }); 254 + 255 + const postUri = "at://did:plc:user2/space.atbb.post/post2"; 256 + 257 + await ctx.db.insert(modActions).values({ 258 + did: ctx.config.forumDid, 259 + rkey: "pin1", 260 + cid: "bafy...pin", 261 + action: "space.atbb.modAction.action#pin", 262 + subjectPostUri: postUri, 263 + createdBy: "did:plc:mod", 264 + createdAt: new Date(), 265 + indexedAt: new Date(), 266 + }); 267 + 268 + const status = await getTopicModStatus(ctx.db, BigInt(2)); 269 + 270 + expect(status.locked).toBe(false); 271 + expect(status.pinned).toBe(true); 272 + }); 273 + 274 + it("handles unlock reversing lock", async () => { 275 + const ctx = await createTestContext(); 276 + 277 + await ctx.db.insert(posts).values({ 278 + id: BigInt(3), 279 + did: "did:plc:user3", 280 + rkey: "post3", 281 + cid: "bafy...3", 282 + text: "Unlocked topic", 283 + forumUri: "at://forum/space.atbb.forum.forum/self", 284 + createdAt: new Date(), 285 + indexedAt: new Date(), 286 + }); 287 + 288 + const postUri = "at://did:plc:user3/space.atbb.post/post3"; 289 + 290 + // Lock then unlock 291 + await ctx.db.insert(modActions).values([ 292 + { 293 + did: ctx.config.forumDid, 294 + rkey: "lock2", 295 + cid: "bafy...lock2", 296 + action: "space.atbb.modAction.action#lock", 297 + subjectPostUri: postUri, 298 + createdBy: "did:plc:mod", 299 + createdAt: new Date("2026-01-01"), 300 + indexedAt: new Date("2026-01-01"), 301 + }, 302 + { 303 + did: ctx.config.forumDid, 304 + rkey: "unlock2", 305 + cid: "bafy...unlock2", 306 + action: "space.atbb.modAction.action#unlock", 307 + subjectPostUri: postUri, 308 + createdBy: "did:plc:mod", 309 + createdAt: new Date("2026-01-02"), 310 + indexedAt: new Date("2026-01-02"), 311 + } 312 + ]); 313 + 314 + const status = await getTopicModStatus(ctx.db, BigInt(3)); 315 + 316 + expect(status.locked).toBe(false); 317 + expect(status.pinned).toBe(false); 318 + }); 319 + }); 320 + ``` 321 + 322 + **Step 2: Run test to verify it fails** 323 + 324 + ```bash 325 + pnpm --filter @atbb/appview test src/routes/__tests__/helpers.test.ts -t "getTopicModStatus" 326 + ``` 327 + 328 + Expected: FAIL with "getTopicModStatus is not defined" 329 + 330 + **Step 3: Write minimal implementation** 331 + 332 + Add to `apps/appview/src/routes/helpers.ts`: 333 + 334 + ```typescript 335 + /** 336 + * Query moderation status for a topic (lock/pin). 337 + * 338 + * @param db Database instance 339 + * @param topicId Internal post ID of the topic (root post) 340 + * @returns { locked: boolean, pinned: boolean } 341 + */ 342 + export async function getTopicModStatus( 343 + db: Database, 344 + topicId: bigint 345 + ): Promise<{ locked: boolean; pinned: boolean }> { 346 + // Look up the topic to get its AT-URI 347 + const [topic] = await db 348 + .select({ 349 + did: posts.did, 350 + rkey: posts.rkey, 351 + }) 352 + .from(posts) 353 + .where(eq(posts.id, topicId)) 354 + .limit(1); 355 + 356 + if (!topic) { 357 + return { locked: false, pinned: false }; 358 + } 359 + 360 + const topicUri = `at://${topic.did}/space.atbb.post/${topic.rkey}`; 361 + 362 + // Query all mod actions for this topic URI 363 + const actions = await db 364 + .select({ 365 + action: modActions.action, 366 + createdAt: modActions.createdAt, 367 + }) 368 + .from(modActions) 369 + .where(eq(modActions.subjectPostUri, topicUri)) 370 + .orderBy(desc(modActions.createdAt)); 371 + 372 + if (actions.length === 0) { 373 + return { locked: false, pinned: false }; 374 + } 375 + 376 + // Most recent action determines current state 377 + const mostRecent = actions[0]; 378 + 379 + return { 380 + locked: mostRecent.action === "space.atbb.modAction.action#lock", 381 + pinned: mostRecent.action === "space.atbb.modAction.action#pin", 382 + }; 383 + } 384 + ``` 385 + 386 + **Step 4: Run test to verify it passes** 387 + 388 + ```bash 389 + pnpm --filter @atbb/appview test src/routes/__tests__/helpers.test.ts -t "getTopicModStatus" 390 + ``` 391 + 392 + Expected: PASS (4 new tests passing) 393 + 394 + **Step 5: Commit** 395 + 396 + ```bash 397 + git add apps/appview/src/routes/helpers.ts apps/appview/src/routes/__tests__/helpers.test.ts 398 + git commit -m "feat(appview): add getTopicModStatus helper for lock/pin status 399 + 400 + - Query lock/pin status for a topic by ID 401 + - Handles lock/unlock and pin/unpin reversals 402 + - Returns current state based on most recent action 403 + - Includes comprehensive test coverage (4 tests)" 404 + ``` 405 + 406 + --- 407 + 408 + ## Task 3: Add helper to query hidden posts 409 + 410 + **Files:** 411 + - Modify: `apps/appview/src/routes/helpers.ts` (add function after `getTopicModStatus`) 412 + - Test: `apps/appview/src/routes/__tests__/helpers.test.ts` 413 + 414 + **Step 1: Write the failing test** 415 + 416 + Add to `apps/appview/src/routes/__tests__/helpers.test.ts`: 417 + 418 + ```typescript 419 + describe("getHiddenPosts", () => { 420 + it("returns empty set when no posts are hidden", async () => { 421 + const ctx = await createTestContext(); 422 + const hidden = await getHiddenPosts(ctx.db, [BigInt(1), BigInt(2)]); 423 + 424 + expect(hidden.size).toBe(0); 425 + }); 426 + 427 + it("returns post IDs with active hide actions", async () => { 428 + const ctx = await createTestContext(); 429 + 430 + // Create posts 431 + await ctx.db.insert(posts).values([ 432 + { 433 + id: BigInt(10), 434 + did: "did:plc:user1", 435 + rkey: "post10", 436 + cid: "bafy...10", 437 + text: "Hidden post", 438 + forumUri: "at://forum/space.atbb.forum.forum/self", 439 + createdAt: new Date(), 440 + indexedAt: new Date(), 441 + }, 442 + { 443 + id: BigInt(11), 444 + did: "did:plc:user2", 445 + rkey: "post11", 446 + cid: "bafy...11", 447 + text: "Visible post", 448 + forumUri: "at://forum/space.atbb.forum.forum/self", 449 + createdAt: new Date(), 450 + indexedAt: new Date(), 451 + }, 452 + ]); 453 + 454 + const hiddenUri = "at://did:plc:user1/space.atbb.post/post10"; 455 + 456 + // Hide one post 457 + await ctx.db.insert(modActions).values({ 458 + did: ctx.config.forumDid, 459 + rkey: "hide1", 460 + cid: "bafy...hide", 461 + action: "space.atbb.modAction.action#delete", 462 + subjectPostUri: hiddenUri, 463 + createdBy: "did:plc:mod", 464 + createdAt: new Date(), 465 + indexedAt: new Date(), 466 + }); 467 + 468 + const hidden = await getHiddenPosts(ctx.db, [BigInt(10), BigInt(11)]); 469 + 470 + expect(hidden.size).toBe(1); 471 + expect(hidden.has(BigInt(10))).toBe(true); 472 + expect(hidden.has(BigInt(11))).toBe(false); 473 + }); 474 + 475 + it("excludes posts with reversed hide actions (undelete)", async () => { 476 + const ctx = await createTestContext(); 477 + 478 + await ctx.db.insert(posts).values({ 479 + id: BigInt(20), 480 + did: "did:plc:user3", 481 + rkey: "post20", 482 + cid: "bafy...20", 483 + text: "Restored post", 484 + forumUri: "at://forum/space.atbb.forum.forum/self", 485 + createdAt: new Date(), 486 + indexedAt: new Date(), 487 + }); 488 + 489 + const postUri = "at://did:plc:user3/space.atbb.post/post20"; 490 + 491 + // Hide then restore 492 + await ctx.db.insert(modActions).values([ 493 + { 494 + did: ctx.config.forumDid, 495 + rkey: "hide2", 496 + cid: "bafy...hide2", 497 + action: "space.atbb.modAction.action#delete", 498 + subjectPostUri: postUri, 499 + createdBy: "did:plc:mod", 500 + createdAt: new Date("2026-01-01"), 501 + indexedAt: new Date("2026-01-01"), 502 + }, 503 + { 504 + did: ctx.config.forumDid, 505 + rkey: "restore2", 506 + cid: "bafy...restore2", 507 + action: "space.atbb.modAction.action#undelete", 508 + subjectPostUri: postUri, 509 + createdBy: "did:plc:mod", 510 + createdAt: new Date("2026-01-02"), 511 + indexedAt: new Date("2026-01-02"), 512 + } 513 + ]); 514 + 515 + const hidden = await getHiddenPosts(ctx.db, [BigInt(20)]); 516 + 517 + expect(hidden.size).toBe(0); 518 + }); 519 + 520 + it("returns empty set for empty input array", async () => { 521 + const ctx = await createTestContext(); 522 + const hidden = await getHiddenPosts(ctx.db, []); 523 + 524 + expect(hidden.size).toBe(0); 525 + }); 526 + }); 527 + ``` 528 + 529 + **Step 2: Run test to verify it fails** 530 + 531 + ```bash 532 + pnpm --filter @atbb/appview test src/routes/__tests__/helpers.test.ts -t "getHiddenPosts" 533 + ``` 534 + 535 + Expected: FAIL with "getHiddenPosts is not defined" 536 + 537 + **Step 3: Write minimal implementation** 538 + 539 + Add to `apps/appview/src/routes/helpers.ts`: 540 + 541 + ```typescript 542 + /** 543 + * Query which posts in a list are currently hidden by moderator action. 544 + * A post is hidden if its most recent modAction is "delete" (not "undelete"). 545 + * 546 + * @param db Database instance 547 + * @param postIds Array of post IDs to check 548 + * @returns Set of hidden post IDs (subset of input) 549 + */ 550 + export async function getHiddenPosts( 551 + db: Database, 552 + postIds: bigint[] 553 + ): Promise<Set<bigint>> { 554 + if (postIds.length === 0) { 555 + return new Set(); 556 + } 557 + 558 + // Look up URIs for these post IDs 559 + const postRecords = await db 560 + .select({ 561 + id: posts.id, 562 + did: posts.did, 563 + rkey: posts.rkey, 564 + }) 565 + .from(posts) 566 + .where(inArray(posts.id, postIds)); 567 + 568 + if (postRecords.length === 0) { 569 + return new Set(); 570 + } 571 + 572 + // Build URI->ID mapping 573 + const uriToId = new Map<string, bigint>(); 574 + const uris: string[] = []; 575 + for (const post of postRecords) { 576 + const uri = `at://${post.did}/space.atbb.post/${post.rkey}`; 577 + uriToId.set(uri, post.id); 578 + uris.push(uri); 579 + } 580 + 581 + // Query mod actions for these URIs 582 + const actions = await db 583 + .select({ 584 + subjectPostUri: modActions.subjectPostUri, 585 + action: modActions.action, 586 + createdAt: modActions.createdAt, 587 + }) 588 + .from(modActions) 589 + .where(inArray(modActions.subjectPostUri, uris)) 590 + .orderBy(desc(modActions.createdAt)); 591 + 592 + // Group by URI and take most recent 593 + const mostRecentByUri = new Map<string, string>(); 594 + for (const row of actions) { 595 + if (row.subjectPostUri && !mostRecentByUri.has(row.subjectPostUri)) { 596 + mostRecentByUri.set(row.subjectPostUri, row.action); 597 + } 598 + } 599 + 600 + // A post is hidden if most recent action is "delete" 601 + const hidden = new Set<bigint>(); 602 + for (const [uri, action] of mostRecentByUri) { 603 + if (action === "space.atbb.modAction.action#delete") { 604 + const postId = uriToId.get(uri); 605 + if (postId !== undefined) { 606 + hidden.add(postId); 607 + } 608 + } 609 + } 610 + 611 + return hidden; 612 + } 613 + ``` 614 + 615 + **Step 4: Run test to verify it passes** 616 + 617 + ```bash 618 + pnpm --filter @atbb/appview test src/routes/__tests__/helpers.test.ts -t "getHiddenPosts" 619 + ``` 620 + 621 + Expected: PASS (4 new tests passing) 622 + 623 + **Step 5: Commit** 624 + 625 + ```bash 626 + git add apps/appview/src/routes/helpers.ts apps/appview/src/routes/__tests__/helpers.test.ts 627 + git commit -m "feat(appview): add getHiddenPosts helper for filtering deleted posts 628 + 629 + - Query hidden status for multiple posts in one query 630 + - Returns Set of post IDs with active delete actions 631 + - Handles delete/undelete reversals correctly 632 + - Includes comprehensive test coverage (4 tests)" 633 + ``` 634 + 635 + --- 636 + 637 + ## Task 4: Enforce ban checks in GET /api/topics/:id (filter banned users' posts) 638 + 639 + **Files:** 640 + - Modify: `apps/appview/src/routes/topics.ts:24-84` (GET /:id handler) 641 + - Test: `apps/appview/src/routes/__tests__/topics.test.ts` 642 + 643 + **Step 1: Write the failing test** 644 + 645 + Add to `apps/appview/src/routes/__tests__/topics.test.ts`: 646 + 647 + ```typescript 648 + describe("GET /api/topics/:id - moderation enforcement", () => { 649 + it("excludes posts from banned users", async () => { 650 + const ctx = await createTestContext(); 651 + const app = new Hono().route("/api/topics", createTopicsRoutes(ctx)); 652 + 653 + // Create topic and replies 654 + const [topic] = await ctx.db.insert(posts).values({ 655 + did: "did:plc:author", 656 + rkey: "topic1", 657 + cid: "bafy...topic", 658 + text: "Topic text", 659 + forumUri: "at://forum/space.atbb.forum.forum/self", 660 + createdAt: new Date(), 661 + indexedAt: new Date(), 662 + }).returning(); 663 + 664 + await ctx.db.insert(posts).values([ 665 + { 666 + did: "did:plc:normal-user", 667 + rkey: "reply1", 668 + cid: "bafy...reply1", 669 + text: "Normal reply", 670 + forumUri: "at://forum/space.atbb.forum.forum/self", 671 + rootPostId: topic.id, 672 + rootUri: `at://did:plc:author/space.atbb.post/topic1`, 673 + createdAt: new Date(), 674 + indexedAt: new Date(), 675 + }, 676 + { 677 + did: "did:plc:banned-user", 678 + rkey: "reply2", 679 + cid: "bafy...reply2", 680 + text: "Banned user reply", 681 + forumUri: "at://forum/space.atbb.forum.forum/self", 682 + rootPostId: topic.id, 683 + rootUri: `at://did:plc:author/space.atbb.post/topic1`, 684 + createdAt: new Date(), 685 + indexedAt: new Date(), 686 + }, 687 + ]); 688 + 689 + // Ban one user 690 + await ctx.db.insert(modActions).values({ 691 + did: ctx.config.forumDid, 692 + rkey: "ban1", 693 + cid: "bafy...ban", 694 + action: "space.atbb.modAction.action#ban", 695 + subjectDid: "did:plc:banned-user", 696 + createdBy: "did:plc:admin", 697 + createdAt: new Date(), 698 + indexedAt: new Date(), 699 + }); 700 + 701 + const res = await app.request(`/api/topics/${topic.id}`); 702 + expect(res.status).toBe(200); 703 + 704 + const data = await res.json(); 705 + expect(data.replies).toHaveLength(1); 706 + expect(data.replies[0].did).toBe("did:plc:normal-user"); 707 + }); 708 + 709 + it("includes replies when ban is reversed", async () => { 710 + const ctx = await createTestContext(); 711 + const app = new Hono().route("/api/topics", createTopicsRoutes(ctx)); 712 + 713 + const [topic] = await ctx.db.insert(posts).values({ 714 + did: "did:plc:author", 715 + rkey: "topic2", 716 + cid: "bafy...topic2", 717 + text: "Topic text", 718 + forumUri: "at://forum/space.atbb.forum.forum/self", 719 + createdAt: new Date(), 720 + indexedAt: new Date(), 721 + }).returning(); 722 + 723 + await ctx.db.insert(posts).values({ 724 + did: "did:plc:unbanned-user", 725 + rkey: "reply3", 726 + cid: "bafy...reply3", 727 + text: "Unbanned user reply", 728 + forumUri: "at://forum/space.atbb.forum.forum/self", 729 + rootPostId: topic.id, 730 + rootUri: `at://did:plc:author/space.atbb.post/topic2`, 731 + createdAt: new Date(), 732 + indexedAt: new Date(), 733 + }); 734 + 735 + // Ban then unban 736 + await ctx.db.insert(modActions).values([ 737 + { 738 + did: ctx.config.forumDid, 739 + rkey: "ban2", 740 + cid: "bafy...ban2", 741 + action: "space.atbb.modAction.action#ban", 742 + subjectDid: "did:plc:unbanned-user", 743 + createdBy: "did:plc:admin", 744 + createdAt: new Date("2026-01-01"), 745 + indexedAt: new Date("2026-01-01"), 746 + }, 747 + { 748 + did: ctx.config.forumDid, 749 + rkey: "unban2", 750 + cid: "bafy...unban2", 751 + action: "space.atbb.modAction.action#unban", 752 + subjectDid: "did:plc:unbanned-user", 753 + createdBy: "did:plc:admin", 754 + createdAt: new Date("2026-01-02"), 755 + indexedAt: new Date("2026-01-02"), 756 + } 757 + ]); 758 + 759 + const res = await app.request(`/api/topics/${topic.id}`); 760 + expect(res.status).toBe(200); 761 + 762 + const data = await res.json(); 763 + expect(data.replies).toHaveLength(1); 764 + expect(data.replies[0].did).toBe("did:plc:unbanned-user"); 765 + }); 766 + }); 767 + ``` 768 + 769 + **Step 2: Run test to verify it fails** 770 + 771 + ```bash 772 + pnpm --filter @atbb/appview test src/routes/__tests__/topics.test.ts -t "moderation enforcement" 773 + ``` 774 + 775 + Expected: FAIL - tests will include banned users' posts 776 + 777 + **Step 3: Implement ban filtering in GET /api/topics/:id** 778 + 779 + Modify `apps/appview/src/routes/topics.ts` GET /:id handler: 780 + 781 + ```typescript 782 + .get("/:id", async (c) => { 783 + const { id } = c.req.param(); 784 + 785 + const topicId = parseBigIntParam(id); 786 + if (topicId === null) { 787 + return c.json({ error: "Invalid topic ID format" }, 400); 788 + } 789 + 790 + try { 791 + // Query the thread starter post 792 + const [topicResult] = await ctx.db 793 + .select({ 794 + post: posts, 795 + author: users, 796 + }) 797 + .from(posts) 798 + .leftJoin(users, eq(posts.did, users.did)) 799 + .where(and(eq(posts.id, topicId), eq(posts.deleted, false))) 800 + .limit(1); 801 + 802 + if (!topicResult) { 803 + return c.json({ error: "Topic not found" }, 404); 804 + } 805 + 806 + // Query all replies (posts where rootPostId = topicId) 807 + const replyResults = await ctx.db 808 + .select({ 809 + post: posts, 810 + author: users, 811 + }) 812 + .from(posts) 813 + .leftJoin(users, eq(posts.did, users.did)) 814 + .where(and(eq(posts.rootPostId, topicId), eq(posts.deleted, false))) 815 + .orderBy(asc(posts.createdAt)) 816 + .limit(1000); 817 + 818 + // NEW: Get banned users and hidden posts 819 + const allUserDids = [ 820 + topicResult.post.did, 821 + ...replyResults.map(r => r.post.did) 822 + ]; 823 + const bannedUsers = await getActiveBans(ctx.db, allUserDids); 824 + 825 + const allPostIds = replyResults.map(r => r.post.id); 826 + const hiddenPosts = await getHiddenPosts(ctx.db, allPostIds); 827 + 828 + // NEW: Filter replies - exclude banned users and hidden posts 829 + const filteredReplies = replyResults.filter(({ post }) => 830 + !bannedUsers.has(post.did) && !hiddenPosts.has(post.id) 831 + ); 832 + 833 + const { post: topicPost, author: topicAuthor } = topicResult; 834 + 835 + return c.json({ 836 + topicId: id, 837 + post: serializePost(topicPost, topicAuthor), 838 + replies: filteredReplies.map(({ post, author }) => 839 + serializePost(post, author) 840 + ), 841 + }); 842 + } catch (error) { 843 + console.error("Failed to query topic", { 844 + operation: "GET /api/topics/:id", 845 + topicId: id, 846 + error: error instanceof Error ? error.message : String(error), 847 + }); 848 + 849 + return c.json( 850 + { 851 + error: "Failed to retrieve topic. Please try again later.", 852 + }, 853 + 500 854 + ); 855 + } 856 + }) 857 + ``` 858 + 859 + Don't forget to import `getActiveBans` and `getHiddenPosts` at the top of the file: 860 + 861 + ```typescript 862 + import { 863 + parseBigIntParam, 864 + serializePost, 865 + validatePostText, 866 + getForumByUri, 867 + getBoardByUri, 868 + getActiveBans, 869 + getHiddenPosts, 870 + } from "./helpers.js"; 871 + ``` 872 + 873 + **Step 4: Run test to verify it passes** 874 + 875 + ```bash 876 + pnpm --filter @atbb/appview test src/routes/__tests__/topics.test.ts -t "moderation enforcement" 877 + ``` 878 + 879 + Expected: PASS (2 tests passing) 880 + 881 + **Step 5: Commit** 882 + 883 + ```bash 884 + git add apps/appview/src/routes/topics.ts apps/appview/src/routes/__tests__/topics.test.ts 885 + git commit -m "feat(appview): filter banned users and hidden posts in GET /api/topics/:id 886 + 887 + - Query active bans for all users in topic thread 888 + - Query hidden status for all replies 889 + - Filter replies to exclude banned users and hidden posts 890 + - Includes tests for ban enforcement and unban reversal" 891 + ``` 892 + 893 + --- 894 + 895 + ## Task 5: Add locked/pinned flags to GET /api/topics/:id response 896 + 897 + **Files:** 898 + - Modify: `apps/appview/src/routes/topics.ts:24-84` (GET /:id handler) 899 + - Test: `apps/appview/src/routes/__tests__/topics.test.ts` 900 + 901 + **Step 1: Write the failing test** 902 + 903 + Add to `apps/appview/src/routes/__tests__/topics.test.ts`: 904 + 905 + ```typescript 906 + describe("GET /api/topics/:id - lock/pin status", () => { 907 + it("includes locked=false and pinned=false when no mod actions", async () => { 908 + const ctx = await createTestContext(); 909 + const app = new Hono().route("/api/topics", createTopicsRoutes(ctx)); 910 + 911 + const [topic] = await ctx.db.insert(posts).values({ 912 + did: "did:plc:author", 913 + rkey: "topic3", 914 + cid: "bafy...topic3", 915 + text: "Normal topic", 916 + forumUri: "at://forum/space.atbb.forum.forum/self", 917 + createdAt: new Date(), 918 + indexedAt: new Date(), 919 + }).returning(); 920 + 921 + const res = await app.request(`/api/topics/${topic.id}`); 922 + expect(res.status).toBe(200); 923 + 924 + const data = await res.json(); 925 + expect(data.locked).toBe(false); 926 + expect(data.pinned).toBe(false); 927 + }); 928 + 929 + it("includes locked=true when topic is locked", async () => { 930 + const ctx = await createTestContext(); 931 + const app = new Hono().route("/api/topics", createTopicsRoutes(ctx)); 932 + 933 + const [topic] = await ctx.db.insert(posts).values({ 934 + did: "did:plc:author", 935 + rkey: "topic4", 936 + cid: "bafy...topic4", 937 + text: "Locked topic", 938 + forumUri: "at://forum/space.atbb.forum.forum/self", 939 + createdAt: new Date(), 940 + indexedAt: new Date(), 941 + }).returning(); 942 + 943 + const topicUri = `at://did:plc:author/space.atbb.post/topic4`; 944 + 945 + await ctx.db.insert(modActions).values({ 946 + did: ctx.config.forumDid, 947 + rkey: "lock1", 948 + cid: "bafy...lock1", 949 + action: "space.atbb.modAction.action#lock", 950 + subjectPostUri: topicUri, 951 + createdBy: "did:plc:mod", 952 + createdAt: new Date(), 953 + indexedAt: new Date(), 954 + }); 955 + 956 + const res = await app.request(`/api/topics/${topic.id}`); 957 + expect(res.status).toBe(200); 958 + 959 + const data = await res.json(); 960 + expect(data.locked).toBe(true); 961 + expect(data.pinned).toBe(false); 962 + }); 963 + 964 + it("includes pinned=true when topic is pinned", async () => { 965 + const ctx = await createTestContext(); 966 + const app = new Hono().route("/api/topics", createTopicsRoutes(ctx)); 967 + 968 + const [topic] = await ctx.db.insert(posts).values({ 969 + did: "did:plc:author", 970 + rkey: "topic5", 971 + cid: "bafy...topic5", 972 + text: "Pinned topic", 973 + forumUri: "at://forum/space.atbb.forum.forum/self", 974 + createdAt: new Date(), 975 + indexedAt: new Date(), 976 + }).returning(); 977 + 978 + const topicUri = `at://did:plc:author/space.atbb.post/topic5`; 979 + 980 + await ctx.db.insert(modActions).values({ 981 + did: ctx.config.forumDid, 982 + rkey: "pin1", 983 + cid: "bafy...pin1", 984 + action: "space.atbb.modAction.action#pin", 985 + subjectPostUri: topicUri, 986 + createdBy: "did:plc:mod", 987 + createdAt: new Date(), 988 + indexedAt: new Date(), 989 + }); 990 + 991 + const res = await app.request(`/api/topics/${topic.id}`); 992 + expect(res.status).toBe(200); 993 + 994 + const data = await res.json(); 995 + expect(data.locked).toBe(false); 996 + expect(data.pinned).toBe(true); 997 + }); 998 + }); 999 + ``` 1000 + 1001 + **Step 2: Run test to verify it fails** 1002 + 1003 + ```bash 1004 + pnpm --filter @atbb/appview test src/routes/__tests__/topics.test.ts -t "lock/pin status" 1005 + ``` 1006 + 1007 + Expected: FAIL - response missing locked/pinned fields 1008 + 1009 + **Step 3: Add lock/pin status to response** 1010 + 1011 + Modify `apps/appview/src/routes/topics.ts` GET /:id handler to include status: 1012 + 1013 + ```typescript 1014 + import { 1015 + parseBigIntParam, 1016 + serializePost, 1017 + validatePostText, 1018 + getForumByUri, 1019 + getBoardByUri, 1020 + getActiveBans, 1021 + getHiddenPosts, 1022 + getTopicModStatus, 1023 + } from "./helpers.js"; 1024 + 1025 + // ... in the GET /:id handler, after filtering replies: 1026 + 1027 + // NEW: Get lock/pin status 1028 + const modStatus = await getTopicModStatus(ctx.db, topicId); 1029 + 1030 + const { post: topicPost, author: topicAuthor } = topicResult; 1031 + 1032 + return c.json({ 1033 + topicId: id, 1034 + locked: modStatus.locked, 1035 + pinned: modStatus.pinned, 1036 + post: serializePost(topicPost, topicAuthor), 1037 + replies: filteredReplies.map(({ post, author }) => 1038 + serializePost(post, author) 1039 + ), 1040 + }); 1041 + ``` 1042 + 1043 + **Step 4: Run test to verify it passes** 1044 + 1045 + ```bash 1046 + pnpm --filter @atbb/appview test src/routes/__tests__/topics.test.ts -t "lock/pin status" 1047 + ``` 1048 + 1049 + Expected: PASS (3 tests passing) 1050 + 1051 + **Step 5: Commit** 1052 + 1053 + ```bash 1054 + git add apps/appview/src/routes/topics.ts apps/appview/src/routes/__tests__/topics.test.ts 1055 + git commit -m "feat(appview): add locked and pinned flags to GET /api/topics/:id 1056 + 1057 + - Query topic lock/pin status from mod actions 1058 + - Include locked and pinned boolean flags in response 1059 + - Defaults to false when no mod actions exist 1060 + - Includes tests for locked, pinned, and normal topics" 1061 + ``` 1062 + 1063 + --- 1064 + 1065 + ## Task 6: Block banned users from POST /api/topics 1066 + 1067 + **Files:** 1068 + - Modify: `apps/appview/src/routes/topics.ts:85-217` (POST / handler) 1069 + - Test: `apps/appview/src/routes/__tests__/topics.test.ts` 1070 + 1071 + **Step 1: Write the failing test** 1072 + 1073 + Add to `apps/appview/src/routes/__tests__/topics.test.ts`: 1074 + 1075 + ```typescript 1076 + describe("POST /api/topics - ban enforcement", () => { 1077 + it("returns 403 when user is banned", async () => { 1078 + const ctx = await createTestContext(); 1079 + const app = new Hono().route("/api/topics", createTopicsRoutes(ctx)); 1080 + 1081 + const bannedUser = await createAuthenticatedUser(ctx, "Banned User"); 1082 + 1083 + // Ban the user 1084 + await ctx.db.insert(modActions).values({ 1085 + did: ctx.config.forumDid, 1086 + rkey: "ban-user1", 1087 + cid: "bafy...ban-user1", 1088 + action: "space.atbb.modAction.action#ban", 1089 + subjectDid: bannedUser.did, 1090 + createdBy: "did:plc:admin", 1091 + createdAt: new Date(), 1092 + indexedAt: new Date(), 1093 + }); 1094 + 1095 + const res = await app.request("/api/topics", { 1096 + method: "POST", 1097 + headers: authHeaders(bannedUser), 1098 + body: JSON.stringify({ 1099 + text: "Banned user trying to post", 1100 + boardUri: "at://forum/space.atbb.forum.board/board1" 1101 + }), 1102 + }); 1103 + 1104 + expect(res.status).toBe(403); 1105 + const data = await res.json(); 1106 + expect(data.error).toContain("banned"); 1107 + }); 1108 + 1109 + it("allows posting when ban is reversed", async () => { 1110 + const ctx = await createTestContext(); 1111 + const app = new Hono().route("/api/topics", createTopicsRoutes(ctx)); 1112 + 1113 + const unbannedUser = await createAuthenticatedUser(ctx, "Unbanned User"); 1114 + 1115 + // Ban then unban 1116 + await ctx.db.insert(modActions).values([ 1117 + { 1118 + did: ctx.config.forumDid, 1119 + rkey: "ban-user2", 1120 + cid: "bafy...ban-user2", 1121 + action: "space.atbb.modAction.action#ban", 1122 + subjectDid: unbannedUser.did, 1123 + createdBy: "did:plc:admin", 1124 + createdAt: new Date("2026-01-01"), 1125 + indexedAt: new Date("2026-01-01"), 1126 + }, 1127 + { 1128 + did: ctx.config.forumDid, 1129 + rkey: "unban-user2", 1130 + cid: "bafy...unban-user2", 1131 + action: "space.atbb.modAction.action#unban", 1132 + subjectDid: unbannedUser.did, 1133 + createdBy: "did:plc:admin", 1134 + createdAt: new Date("2026-01-02"), 1135 + indexedAt: new Date("2026-01-02"), 1136 + } 1137 + ]); 1138 + 1139 + // Set up board 1140 + await ctx.db.insert(boards).values({ 1141 + did: ctx.config.forumDid, 1142 + rkey: "board1", 1143 + cid: "bafy...board1", 1144 + name: "General", 1145 + categoryUri: "at://forum/space.atbb.forum.category/cat1", 1146 + createdAt: new Date(), 1147 + indexedAt: new Date(), 1148 + }); 1149 + 1150 + const res = await app.request("/api/topics", { 1151 + method: "POST", 1152 + headers: authHeaders(unbannedUser), 1153 + body: JSON.stringify({ 1154 + text: "Unbanned user can post", 1155 + boardUri: "at://forum/space.atbb.forum.board/board1" 1156 + }), 1157 + }); 1158 + 1159 + expect(res.status).toBe(201); 1160 + }); 1161 + }); 1162 + ``` 1163 + 1164 + **Step 2: Run test to verify it fails** 1165 + 1166 + ```bash 1167 + pnpm --filter @atbb/appview test src/routes/__tests__/topics.test.ts -t "POST /api/topics - ban enforcement" 1168 + ``` 1169 + 1170 + Expected: FAIL - banned users can still post 1171 + 1172 + **Step 3: Add ban check to POST /api/topics** 1173 + 1174 + Modify `apps/appview/src/routes/topics.ts` POST / handler: 1175 + 1176 + ```typescript 1177 + .post("/", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.createTopics"), async (c) => { 1178 + const user = c.get("user")!; 1179 + 1180 + // NEW: Check if user is banned 1181 + const bannedUsers = await getActiveBans(ctx.db, [user.did]); 1182 + if (bannedUsers.has(user.did)) { 1183 + return c.json( 1184 + { error: "You are banned from posting in this forum" }, 1185 + 403 1186 + ); 1187 + } 1188 + 1189 + // Parse and validate request body 1190 + let body: any; 1191 + // ... rest of handler unchanged 1192 + ``` 1193 + 1194 + **Step 4: Run test to verify it passes** 1195 + 1196 + ```bash 1197 + pnpm --filter @atbb/appview test src/routes/__tests__/topics.test.ts -t "POST /api/topics - ban enforcement" 1198 + ``` 1199 + 1200 + Expected: PASS (2 tests passing) 1201 + 1202 + **Step 5: Commit** 1203 + 1204 + ```bash 1205 + git add apps/appview/src/routes/topics.ts apps/appview/src/routes/__tests__/topics.test.ts 1206 + git commit -m "feat(appview): block banned users from creating topics (POST /api/topics) 1207 + 1208 + - Check user ban status before allowing topic creation 1209 + - Return 403 with clear error message for banned users 1210 + - Includes tests for ban enforcement and unban reversal" 1211 + ``` 1212 + 1213 + --- 1214 + 1215 + ## Task 7: Block banned users and locked topics in POST /api/posts 1216 + 1217 + **Files:** 1218 + - Modify: `apps/appview/src/routes/posts.ts:16-147` (POST / handler) 1219 + - Test: `apps/appview/src/routes/__tests__/posts.test.ts` 1220 + 1221 + **Step 1: Write the failing test** 1222 + 1223 + Add to `apps/appview/src/routes/__tests__/posts.test.ts`: 1224 + 1225 + ```typescript 1226 + describe("POST /api/posts - moderation enforcement", () => { 1227 + it("returns 403 when user is banned", async () => { 1228 + const ctx = await createTestContext(); 1229 + const app = new Hono().route("/api/posts", createPostsRoutes(ctx)); 1230 + 1231 + const bannedUser = await createAuthenticatedUser(ctx, "Banned User"); 1232 + 1233 + // Create a topic to reply to 1234 + const [topic] = await ctx.db.insert(posts).values({ 1235 + did: "did:plc:author", 1236 + rkey: "topic1", 1237 + cid: "bafy...topic1", 1238 + text: "Topic", 1239 + forumUri: "at://forum/space.atbb.forum.forum/self", 1240 + createdAt: new Date(), 1241 + indexedAt: new Date(), 1242 + }).returning(); 1243 + 1244 + // Ban the user 1245 + await ctx.db.insert(modActions).values({ 1246 + did: ctx.config.forumDid, 1247 + rkey: "ban-replier", 1248 + cid: "bafy...ban-replier", 1249 + action: "space.atbb.modAction.action#ban", 1250 + subjectDid: bannedUser.did, 1251 + createdBy: "did:plc:admin", 1252 + createdAt: new Date(), 1253 + indexedAt: new Date(), 1254 + }); 1255 + 1256 + const res = await app.request("/api/posts", { 1257 + method: "POST", 1258 + headers: authHeaders(bannedUser), 1259 + body: JSON.stringify({ 1260 + text: "Banned user trying to reply", 1261 + rootPostId: topic.id.toString(), 1262 + parentPostId: topic.id.toString(), 1263 + }), 1264 + }); 1265 + 1266 + expect(res.status).toBe(403); 1267 + const data = await res.json(); 1268 + expect(data.error).toContain("banned"); 1269 + }); 1270 + 1271 + it("returns 403 when topic is locked", async () => { 1272 + const ctx = await createTestContext(); 1273 + const app = new Hono().route("/api/posts", createPostsRoutes(ctx)); 1274 + 1275 + const normalUser = await createAuthenticatedUser(ctx, "Normal User"); 1276 + 1277 + // Create a locked topic 1278 + const [topic] = await ctx.db.insert(posts).values({ 1279 + did: "did:plc:author", 1280 + rkey: "locked-topic", 1281 + cid: "bafy...locked", 1282 + text: "Locked topic", 1283 + forumUri: "at://forum/space.atbb.forum.forum/self", 1284 + createdAt: new Date(), 1285 + indexedAt: new Date(), 1286 + }).returning(); 1287 + 1288 + const topicUri = `at://did:plc:author/space.atbb.post/locked-topic`; 1289 + 1290 + // Lock the topic 1291 + await ctx.db.insert(modActions).values({ 1292 + did: ctx.config.forumDid, 1293 + rkey: "lock-topic1", 1294 + cid: "bafy...lock-topic1", 1295 + action: "space.atbb.modAction.action#lock", 1296 + subjectPostUri: topicUri, 1297 + createdBy: "did:plc:mod", 1298 + createdAt: new Date(), 1299 + indexedAt: new Date(), 1300 + }); 1301 + 1302 + const res = await app.request("/api/posts", { 1303 + method: "POST", 1304 + headers: authHeaders(normalUser), 1305 + body: JSON.stringify({ 1306 + text: "Trying to reply to locked topic", 1307 + rootPostId: topic.id.toString(), 1308 + parentPostId: topic.id.toString(), 1309 + }), 1310 + }); 1311 + 1312 + expect(res.status).toBe(403); 1313 + const data = await res.json(); 1314 + expect(data.error).toContain("locked"); 1315 + }); 1316 + 1317 + it("allows posting when topic is unlocked", async () => { 1318 + const ctx = await createTestContext(); 1319 + const app = new Hono().route("/api/posts", createPostsRoutes(ctx)); 1320 + 1321 + const normalUser = await createAuthenticatedUser(ctx, "Normal User"); 1322 + 1323 + // Create a topic 1324 + const [topic] = await ctx.db.insert(posts).values({ 1325 + did: "did:plc:author", 1326 + rkey: "unlocked-topic", 1327 + cid: "bafy...unlocked", 1328 + text: "Unlocked topic", 1329 + forumUri: "at://forum/space.atbb.forum.forum/self", 1330 + createdAt: new Date(), 1331 + indexedAt: new Date(), 1332 + }).returning(); 1333 + 1334 + const topicUri = `at://did:plc:author/space.atbb.post/unlocked-topic`; 1335 + 1336 + // Lock then unlock 1337 + await ctx.db.insert(modActions).values([ 1338 + { 1339 + did: ctx.config.forumDid, 1340 + rkey: "lock-topic2", 1341 + cid: "bafy...lock-topic2", 1342 + action: "space.atbb.modAction.action#lock", 1343 + subjectPostUri: topicUri, 1344 + createdBy: "did:plc:mod", 1345 + createdAt: new Date("2026-01-01"), 1346 + indexedAt: new Date("2026-01-01"), 1347 + }, 1348 + { 1349 + did: ctx.config.forumDid, 1350 + rkey: "unlock-topic2", 1351 + cid: "bafy...unlock-topic2", 1352 + action: "space.atbb.modAction.action#unlock", 1353 + subjectPostUri: topicUri, 1354 + createdBy: "did:plc:mod", 1355 + createdAt: new Date("2026-01-02"), 1356 + indexedAt: new Date("2026-01-02"), 1357 + } 1358 + ]); 1359 + 1360 + const res = await app.request("/api/posts", { 1361 + method: "POST", 1362 + headers: authHeaders(normalUser), 1363 + body: JSON.stringify({ 1364 + text: "Reply to unlocked topic", 1365 + rootPostId: topic.id.toString(), 1366 + parentPostId: topic.id.toString(), 1367 + }), 1368 + }); 1369 + 1370 + expect(res.status).toBe(201); 1371 + }); 1372 + }); 1373 + ``` 1374 + 1375 + **Step 2: Run test to verify it fails** 1376 + 1377 + ```bash 1378 + pnpm --filter @atbb/appview test src/routes/__tests__/posts.test.ts -t "moderation enforcement" 1379 + ``` 1380 + 1381 + Expected: FAIL - banned users can reply, locked topics accept replies 1382 + 1383 + **Step 3: Add ban and lock checks to POST /api/posts** 1384 + 1385 + Modify `apps/appview/src/routes/posts.ts`: 1386 + 1387 + ```typescript 1388 + import { 1389 + validatePostText, 1390 + parseBigIntParam, 1391 + getPostsByIds, 1392 + validateReplyParent, 1393 + getActiveBans, 1394 + getTopicModStatus, 1395 + } from "./helpers.js"; 1396 + 1397 + export function createPostsRoutes(ctx: AppContext) { 1398 + return new Hono<{ Variables: Variables }>().post("/", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.createPosts"), async (c) => { 1399 + const user = c.get("user")!; 1400 + 1401 + // NEW: Check if user is banned 1402 + const bannedUsers = await getActiveBans(ctx.db, [user.did]); 1403 + if (bannedUsers.has(user.did)) { 1404 + return c.json( 1405 + { error: "You are banned from posting in this forum" }, 1406 + 403 1407 + ); 1408 + } 1409 + 1410 + // Parse and validate request body 1411 + let body: any; 1412 + try { 1413 + body = await c.req.json(); 1414 + } catch { 1415 + return c.json({ error: "Invalid JSON in request body" }, 400); 1416 + } 1417 + 1418 + const { text, rootPostId: rootIdStr, parentPostId: parentIdStr } = body; 1419 + 1420 + // Validate text 1421 + const validation = validatePostText(text); 1422 + if (!validation.valid) { 1423 + return c.json({ error: validation.error }, 400); 1424 + } 1425 + 1426 + // Parse IDs 1427 + const rootId = parseBigIntParam(rootIdStr); 1428 + const parentId = parseBigIntParam(parentIdStr); 1429 + 1430 + if (rootId === null || parentId === null) { 1431 + return c.json( 1432 + { 1433 + error: "Invalid post ID format. IDs must be numeric strings.", 1434 + }, 1435 + 400 1436 + ); 1437 + } 1438 + 1439 + // NEW: Check if topic is locked 1440 + const modStatus = await getTopicModStatus(ctx.db, rootId); 1441 + if (modStatus.locked) { 1442 + return c.json( 1443 + { error: "This topic is locked and no longer accepting replies" }, 1444 + 403 1445 + ); 1446 + } 1447 + 1448 + try { 1449 + // ... rest of handler unchanged 1450 + ``` 1451 + 1452 + **Step 4: Run test to verify it passes** 1453 + 1454 + ```bash 1455 + pnpm --filter @atbb/appview test src/routes/__tests__/posts.test.ts -t "moderation enforcement" 1456 + ``` 1457 + 1458 + Expected: PASS (3 tests passing) 1459 + 1460 + **Step 5: Commit** 1461 + 1462 + ```bash 1463 + git add apps/appview/src/routes/posts.ts apps/appview/src/routes/__tests__/posts.test.ts 1464 + git commit -m "feat(appview): block banned users and locked topics in POST /api/posts 1465 + 1466 + - Check user ban status before allowing reply creation 1467 + - Check topic lock status before allowing replies 1468 + - Return 403 with clear error messages 1469 + - Includes tests for ban, lock, and unlock scenarios" 1470 + ``` 1471 + 1472 + --- 1473 + 1474 + ## Task 8: Run full test suite and verify all tests pass 1475 + 1476 + **Step 1: Run all tests** 1477 + 1478 + ```bash 1479 + pnpm test 1480 + ``` 1481 + 1482 + Expected: All tests pass (no failures) 1483 + 1484 + **Step 2: Verify performance** 1485 + 1486 + Run a quick manual test to ensure mod action queries don't add significant latency: 1487 + 1488 + ```bash 1489 + # Start the dev server 1490 + pnpm dev 1491 + 1492 + # In another terminal, measure response time 1493 + time curl http://localhost:3000/api/topics/1 1494 + ``` 1495 + 1496 + Expected: Response time < 100ms (mod action queries should add < 5ms) 1497 + 1498 + **Step 3: Final verification** 1499 + 1500 + Check that all acceptance criteria are met: 1501 + 1502 + - [x] Banned users' posts excluded from topic replies 1503 + - [x] Locked topics return `locked: true` flag in API response 1504 + - [x] POST /api/posts returns 403 for locked topics with clear error message 1505 + - [x] POST /api/topics and POST /api/posts return 403 for banned users 1506 + - [x] Hidden posts excluded from all read responses 1507 + - [x] Performance: mod action checks add <5ms to query time 1508 + - [x] Tests for each enforcement rule (banned, locked, hidden) 1509 + - [x] Tests verify enforcement doesn't break normal (non-moderated) responses 1510 + 1511 + --- 1512 + 1513 + ## Task 9: Update Linear issue status 1514 + 1515 + **Step 1: Update Linear issue** 1516 + 1517 + Use the Linear CLI or web interface to: 1518 + 1519 + 1. Change status to "Done" 1520 + 2. Add comment documenting implementation: 1521 + 1522 + ``` 1523 + Implementation complete: 1524 + 1525 + ✅ Helper functions added: 1526 + - getActiveBans(db, dids[]) - returns Set of banned DIDs 1527 + - getTopicModStatus(db, topicId) - returns { locked, pinned } 1528 + - getHiddenPosts(db, postIds[]) - returns Set of hidden post IDs 1529 + 1530 + ✅ Read-path enforcement: 1531 + - GET /api/topics/:id filters banned users' posts 1532 + - GET /api/topics/:id includes locked/pinned flags 1533 + - GET /api/topics/:id filters hidden posts 1534 + 1535 + ✅ Write-path enforcement: 1536 + - POST /api/topics blocks banned users (403) 1537 + - POST /api/posts blocks banned users (403) 1538 + - POST /api/posts blocks replies to locked topics (403) 1539 + 1540 + ✅ Test coverage: 1541 + - 17 new tests covering all enforcement scenarios 1542 + - Tests verify ban/unban, lock/unlock reversals 1543 + - Tests verify normal operation unaffected 1544 + 1545 + Files modified: 1546 + - apps/appview/src/routes/helpers.ts (3 new functions) 1547 + - apps/appview/src/routes/topics.ts (ban filtering, lock/pin flags) 1548 + - apps/appview/src/routes/posts.ts (ban + lock checks) 1549 + - apps/appview/src/routes/__tests__/*.test.ts (17 new tests) 1550 + 1551 + Performance: Mod action queries use indexed lookups, negligible overhead (<5ms). 1552 + ``` 1553 + 1554 + --- 1555 + 1556 + ## Task 10: Create pull request 1557 + 1558 + **Step 1: Push branch** 1559 + 1560 + ```bash 1561 + git push -u origin root/atb-20-enforce-mod-actions-in-read-path-api-responses 1562 + ``` 1563 + 1564 + **Step 2: Create PR** 1565 + 1566 + Use `gh pr create` with comprehensive description: 1567 + 1568 + ```bash 1569 + gh pr create --title "feat(appview): enforce mod actions in read-path API responses (ATB-20)" --body "$(cat <<'EOF' 1570 + ## Summary 1571 + 1572 + Implements moderation action enforcement in read-path API responses and write-path endpoints. 1573 + 1574 + - **Banned users**: Posts excluded from topic view, cannot create topics/posts 1575 + - **Locked topics**: Read-only, show `locked: true` flag, reject new replies 1576 + - **Hidden posts**: Excluded from all read responses 1577 + - **Pinned topics**: Show `pinned: true` flag in response 1578 + 1579 + ## Changes 1580 + 1581 + ### Helper functions (apps/appview/src/routes/helpers.ts) 1582 + 1583 + - `getActiveBans(db, dids[])` - Query active bans for multiple users 1584 + - `getTopicModStatus(db, topicId)` - Query lock/pin status for a topic 1585 + - `getHiddenPosts(db, postIds[])` - Query hidden status for multiple posts 1586 + 1587 + All helpers handle action reversals (ban→unban, lock→unlock, hide→unhide). 1588 + 1589 + ### Read-path enforcement (GET /api/topics/:id) 1590 + 1591 + - Filters replies from banned users 1592 + - Filters hidden posts 1593 + - Includes `locked` and `pinned` boolean flags in response 1594 + 1595 + ### Write-path enforcement 1596 + 1597 + - POST /api/topics - Rejects banned users (403) 1598 + - POST /api/posts - Rejects banned users (403) 1599 + - POST /api/posts - Rejects replies to locked topics (403) 1600 + 1601 + ## Test Coverage 1602 + 1603 + 17 new tests covering: 1604 + - Ban filtering in topic view 1605 + - Ban enforcement in write endpoints 1606 + - Lock status in topic view 1607 + - Lock enforcement in write endpoints 1608 + - Hidden post filtering 1609 + - Action reversals (unban, unlock, unhide) 1610 + - Normal operation unaffected by mod system 1611 + 1612 + ## Performance 1613 + 1614 + Mod action queries use indexed lookups on `mod_actions` table: 1615 + - Query by `subjectDid` (user bans) - indexed 1616 + - Query by `subjectPostUri` (topic locks/hides) - indexed 1617 + - Overhead: <5ms per request (measured locally) 1618 + 1619 + ## Testing 1620 + 1621 + ```bash 1622 + # All tests pass 1623 + pnpm test 1624 + 1625 + # Specific test files 1626 + pnpm --filter @atbb/appview test src/routes/__tests__/helpers.test.ts 1627 + pnpm --filter @atbb/appview test src/routes/__tests__/topics.test.ts 1628 + pnpm --filter @atbb/appview test src/routes/__tests__/posts.test.ts 1629 + ``` 1630 + 1631 + ## Closes 1632 + 1633 + - ATB-20 1634 + EOF 1635 + )" 1636 + ``` 1637 + 1638 + --- 1639 + 1640 + ## Summary 1641 + 1642 + This plan implements comprehensive moderation action enforcement across read and write API endpoints: 1643 + 1644 + **Helper functions** provide efficient database queries for ban status, topic lock/pin status, and hidden posts. All helpers correctly handle action reversals. 1645 + 1646 + **Read-path enforcement** filters banned users' posts and hidden posts from topic views, and adds `locked`/`pinned` flags to indicate moderation status. 1647 + 1648 + **Write-path enforcement** blocks banned users from creating topics or posts, and blocks replies to locked topics with clear 403 error messages. 1649 + 1650 + **Test coverage** includes 17 new tests verifying all enforcement scenarios, reversals, and normal operation. 1651 + 1652 + **Performance** remains excellent with <5ms overhead from indexed mod action queries.
+2
packages/db/src/schema.ts
··· 175 175 }, 176 176 (table) => [ 177 177 uniqueIndex("mod_actions_did_rkey_idx").on(table.did, table.rkey), 178 + index("mod_actions_subject_did_idx").on(table.subjectDid), 179 + index("mod_actions_subject_post_uri_idx").on(table.subjectPostUri), 178 180 ] 179 181 ); 180 182