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

feat: add title field to topics (#51)

* feat: add title field to topics

Topics (thread-starter posts) now have a dedicated title field separate
from the post body text. This adds the field across the full stack:

- Lexicon: optional `title` string on space.atbb.post (max 120 graphemes)
- Database: nullable `title` column on posts table with migration
- Indexer: stores title from incoming post records
- API: POST /api/topics validates and requires title for new topics
- Web UI: title input on new-topic form, title display in topic list and
thread view (falls back to text slice for older titleless posts)
- Tests: title validation, serialization, and form submission tests
- Bruno: updated API collection docs

https://claude.ai/code/session_01AFY6D5413QU48JULXnSQ5Z

* fix(review): address PR #51 review feedback on topic title feature

- Add validateTopicTitle unit tests mirroring validatePostText suite:
boundary at 120 graphemes, emoji grapheme counting, trim behavior,
and non-string input rejection (null/undefined/number/object)
- Add GET /api/topics/:id round-trip test asserting data.post.title
- Add backward-compat test for null title (pre-migration rows)
- Add title field to serializePost JSDoc response shape
- Add minGraphemes: 1 to post.yaml to close lexicon/AppView gap
- Fix Bruno Create Topic.bru: 400 error list now includes missing title;
constraint description changed to "max 120 graphemes; required"
- Add title: null to Get Topic.bru reply example
- Remove misleading maxlength={1000} from title input (server validates graphemes)
- Change || to ?? for null title fallback in boards.tsx TopicRow

Tracks ATB-35 (strip title from reply records at index time)

* fix(review): address PR #51 second round review feedback

- Fix || → ?? for null title fallback in topics.tsx (web)
- Split combined DB+PDS try block into two separate blocks so a
database error (which may surface as "fetch failed" via postgres.js)
cannot be misclassified as a PDS failure and return the wrong message
- Add comment explaining why title is enforced as required in AppView
despite being optional in the lexicon (AT Protocol schemas cannot
express per-use-case requirements)
- Update 503 database error test to mock getForumByUri instead of
putRecord, accurately targeting the DB lookup phase
- File ATB-36 to track stripping title from reply records at index time

* fix(review): extract ban check to middleware and split DB lookup try blocks

- Add requireNotBanned middleware to permissions.ts so banned users see
"You are banned" before requirePermission can return "Insufficient
permissions" — middleware ordering now encodes the correct UX priority
- Split getForumByUri and getBoardByUri into separate try blocks in
topics.ts so operators can distinguish forum vs board lookup failures
in production logs
- Update vi.mock in topics.test.ts and posts.test.ts to use importOriginal
so requireNotBanned executes its real implementation in ban enforcement
tests while requirePermission remains a pass-through
- Update ban error test operation strings from route-scoped labels to
"requireNotBanned" to match the new middleware location

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by

Malpercio
Claude
and committed by
GitHub
f7db4a64 7730ec8e

+1582 -153
+1
apps/appview/drizzle/0007_jittery_hellion.sql
···
··· 1 + ALTER TABLE "posts" ADD COLUMN "title" text;
+1087
apps/appview/drizzle/meta/0007_snapshot.json
···
··· 1 + { 2 + "id": "d1681012-5578-4505-9467-f4e6096facc5", 3 + "prevId": "3d8caf75-4733-4fc0-9d1a-50f24eaebb43", 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 + "title": { 696 + "name": "title", 697 + "type": "text", 698 + "primaryKey": false, 699 + "notNull": false 700 + }, 701 + "text": { 702 + "name": "text", 703 + "type": "text", 704 + "primaryKey": false, 705 + "notNull": true 706 + }, 707 + "forum_uri": { 708 + "name": "forum_uri", 709 + "type": "text", 710 + "primaryKey": false, 711 + "notNull": false 712 + }, 713 + "board_uri": { 714 + "name": "board_uri", 715 + "type": "text", 716 + "primaryKey": false, 717 + "notNull": false 718 + }, 719 + "board_id": { 720 + "name": "board_id", 721 + "type": "bigint", 722 + "primaryKey": false, 723 + "notNull": false 724 + }, 725 + "root_post_id": { 726 + "name": "root_post_id", 727 + "type": "bigint", 728 + "primaryKey": false, 729 + "notNull": false 730 + }, 731 + "parent_post_id": { 732 + "name": "parent_post_id", 733 + "type": "bigint", 734 + "primaryKey": false, 735 + "notNull": false 736 + }, 737 + "root_uri": { 738 + "name": "root_uri", 739 + "type": "text", 740 + "primaryKey": false, 741 + "notNull": false 742 + }, 743 + "parent_uri": { 744 + "name": "parent_uri", 745 + "type": "text", 746 + "primaryKey": false, 747 + "notNull": false 748 + }, 749 + "created_at": { 750 + "name": "created_at", 751 + "type": "timestamp with time zone", 752 + "primaryKey": false, 753 + "notNull": true 754 + }, 755 + "indexed_at": { 756 + "name": "indexed_at", 757 + "type": "timestamp with time zone", 758 + "primaryKey": false, 759 + "notNull": true 760 + }, 761 + "deleted": { 762 + "name": "deleted", 763 + "type": "boolean", 764 + "primaryKey": false, 765 + "notNull": true, 766 + "default": false 767 + } 768 + }, 769 + "indexes": { 770 + "posts_did_rkey_idx": { 771 + "name": "posts_did_rkey_idx", 772 + "columns": [ 773 + { 774 + "expression": "did", 775 + "isExpression": false, 776 + "asc": true, 777 + "nulls": "last" 778 + }, 779 + { 780 + "expression": "rkey", 781 + "isExpression": false, 782 + "asc": true, 783 + "nulls": "last" 784 + } 785 + ], 786 + "isUnique": true, 787 + "concurrently": false, 788 + "method": "btree", 789 + "with": {} 790 + }, 791 + "posts_forum_uri_idx": { 792 + "name": "posts_forum_uri_idx", 793 + "columns": [ 794 + { 795 + "expression": "forum_uri", 796 + "isExpression": false, 797 + "asc": true, 798 + "nulls": "last" 799 + } 800 + ], 801 + "isUnique": false, 802 + "concurrently": false, 803 + "method": "btree", 804 + "with": {} 805 + }, 806 + "posts_board_id_idx": { 807 + "name": "posts_board_id_idx", 808 + "columns": [ 809 + { 810 + "expression": "board_id", 811 + "isExpression": false, 812 + "asc": true, 813 + "nulls": "last" 814 + } 815 + ], 816 + "isUnique": false, 817 + "concurrently": false, 818 + "method": "btree", 819 + "with": {} 820 + }, 821 + "posts_board_uri_idx": { 822 + "name": "posts_board_uri_idx", 823 + "columns": [ 824 + { 825 + "expression": "board_uri", 826 + "isExpression": false, 827 + "asc": true, 828 + "nulls": "last" 829 + } 830 + ], 831 + "isUnique": false, 832 + "concurrently": false, 833 + "method": "btree", 834 + "with": {} 835 + }, 836 + "posts_root_post_id_idx": { 837 + "name": "posts_root_post_id_idx", 838 + "columns": [ 839 + { 840 + "expression": "root_post_id", 841 + "isExpression": false, 842 + "asc": true, 843 + "nulls": "last" 844 + } 845 + ], 846 + "isUnique": false, 847 + "concurrently": false, 848 + "method": "btree", 849 + "with": {} 850 + } 851 + }, 852 + "foreignKeys": { 853 + "posts_did_users_did_fk": { 854 + "name": "posts_did_users_did_fk", 855 + "tableFrom": "posts", 856 + "tableTo": "users", 857 + "columnsFrom": [ 858 + "did" 859 + ], 860 + "columnsTo": [ 861 + "did" 862 + ], 863 + "onDelete": "no action", 864 + "onUpdate": "no action" 865 + }, 866 + "posts_board_id_boards_id_fk": { 867 + "name": "posts_board_id_boards_id_fk", 868 + "tableFrom": "posts", 869 + "tableTo": "boards", 870 + "columnsFrom": [ 871 + "board_id" 872 + ], 873 + "columnsTo": [ 874 + "id" 875 + ], 876 + "onDelete": "no action", 877 + "onUpdate": "no action" 878 + }, 879 + "posts_root_post_id_posts_id_fk": { 880 + "name": "posts_root_post_id_posts_id_fk", 881 + "tableFrom": "posts", 882 + "tableTo": "posts", 883 + "columnsFrom": [ 884 + "root_post_id" 885 + ], 886 + "columnsTo": [ 887 + "id" 888 + ], 889 + "onDelete": "no action", 890 + "onUpdate": "no action" 891 + }, 892 + "posts_parent_post_id_posts_id_fk": { 893 + "name": "posts_parent_post_id_posts_id_fk", 894 + "tableFrom": "posts", 895 + "tableTo": "posts", 896 + "columnsFrom": [ 897 + "parent_post_id" 898 + ], 899 + "columnsTo": [ 900 + "id" 901 + ], 902 + "onDelete": "no action", 903 + "onUpdate": "no action" 904 + } 905 + }, 906 + "compositePrimaryKeys": {}, 907 + "uniqueConstraints": {}, 908 + "policies": {}, 909 + "checkConstraints": {}, 910 + "isRLSEnabled": false 911 + }, 912 + "public.roles": { 913 + "name": "roles", 914 + "schema": "", 915 + "columns": { 916 + "id": { 917 + "name": "id", 918 + "type": "bigserial", 919 + "primaryKey": true, 920 + "notNull": true 921 + }, 922 + "did": { 923 + "name": "did", 924 + "type": "text", 925 + "primaryKey": false, 926 + "notNull": true 927 + }, 928 + "rkey": { 929 + "name": "rkey", 930 + "type": "text", 931 + "primaryKey": false, 932 + "notNull": true 933 + }, 934 + "cid": { 935 + "name": "cid", 936 + "type": "text", 937 + "primaryKey": false, 938 + "notNull": true 939 + }, 940 + "name": { 941 + "name": "name", 942 + "type": "text", 943 + "primaryKey": false, 944 + "notNull": true 945 + }, 946 + "description": { 947 + "name": "description", 948 + "type": "text", 949 + "primaryKey": false, 950 + "notNull": false 951 + }, 952 + "permissions": { 953 + "name": "permissions", 954 + "type": "text[]", 955 + "primaryKey": false, 956 + "notNull": true, 957 + "default": "'{}'::text[]" 958 + }, 959 + "priority": { 960 + "name": "priority", 961 + "type": "integer", 962 + "primaryKey": false, 963 + "notNull": true 964 + }, 965 + "created_at": { 966 + "name": "created_at", 967 + "type": "timestamp with time zone", 968 + "primaryKey": false, 969 + "notNull": true 970 + }, 971 + "indexed_at": { 972 + "name": "indexed_at", 973 + "type": "timestamp with time zone", 974 + "primaryKey": false, 975 + "notNull": true 976 + } 977 + }, 978 + "indexes": { 979 + "roles_did_rkey_idx": { 980 + "name": "roles_did_rkey_idx", 981 + "columns": [ 982 + { 983 + "expression": "did", 984 + "isExpression": false, 985 + "asc": true, 986 + "nulls": "last" 987 + }, 988 + { 989 + "expression": "rkey", 990 + "isExpression": false, 991 + "asc": true, 992 + "nulls": "last" 993 + } 994 + ], 995 + "isUnique": true, 996 + "concurrently": false, 997 + "method": "btree", 998 + "with": {} 999 + }, 1000 + "roles_did_idx": { 1001 + "name": "roles_did_idx", 1002 + "columns": [ 1003 + { 1004 + "expression": "did", 1005 + "isExpression": false, 1006 + "asc": true, 1007 + "nulls": "last" 1008 + } 1009 + ], 1010 + "isUnique": false, 1011 + "concurrently": false, 1012 + "method": "btree", 1013 + "with": {} 1014 + }, 1015 + "roles_did_name_idx": { 1016 + "name": "roles_did_name_idx", 1017 + "columns": [ 1018 + { 1019 + "expression": "did", 1020 + "isExpression": false, 1021 + "asc": true, 1022 + "nulls": "last" 1023 + }, 1024 + { 1025 + "expression": "name", 1026 + "isExpression": false, 1027 + "asc": true, 1028 + "nulls": "last" 1029 + } 1030 + ], 1031 + "isUnique": false, 1032 + "concurrently": false, 1033 + "method": "btree", 1034 + "with": {} 1035 + } 1036 + }, 1037 + "foreignKeys": {}, 1038 + "compositePrimaryKeys": {}, 1039 + "uniqueConstraints": {}, 1040 + "policies": {}, 1041 + "checkConstraints": {}, 1042 + "isRLSEnabled": false 1043 + }, 1044 + "public.users": { 1045 + "name": "users", 1046 + "schema": "", 1047 + "columns": { 1048 + "did": { 1049 + "name": "did", 1050 + "type": "text", 1051 + "primaryKey": true, 1052 + "notNull": true 1053 + }, 1054 + "handle": { 1055 + "name": "handle", 1056 + "type": "text", 1057 + "primaryKey": false, 1058 + "notNull": false 1059 + }, 1060 + "indexed_at": { 1061 + "name": "indexed_at", 1062 + "type": "timestamp with time zone", 1063 + "primaryKey": false, 1064 + "notNull": true 1065 + } 1066 + }, 1067 + "indexes": {}, 1068 + "foreignKeys": {}, 1069 + "compositePrimaryKeys": {}, 1070 + "uniqueConstraints": {}, 1071 + "policies": {}, 1072 + "checkConstraints": {}, 1073 + "isRLSEnabled": false 1074 + } 1075 + }, 1076 + "enums": {}, 1077 + "schemas": {}, 1078 + "sequences": {}, 1079 + "roles": {}, 1080 + "policies": {}, 1081 + "views": {}, 1082 + "_meta": { 1083 + "columns": {}, 1084 + "schemas": {}, 1085 + "tables": {} 1086 + } 1087 + }
+7
apps/appview/drizzle/meta/_journal.json
··· 50 "when": 1771285570721, 51 "tag": "0006_absurd_karen_page", 52 "breakpoints": true 53 } 54 ] 55 }
··· 50 "when": 1771285570721, 51 "tag": "0006_absurd_karen_page", 52 "breakpoints": true 53 + }, 54 + { 55 + "idx": 7, 56 + "version": "7", 57 + "when": 1771817927092, 58 + "tag": "0007_jittery_hellion", 59 + "breakpoints": true 60 } 61 ] 62 }
+2
apps/appview/src/lib/indexer.ts
··· 112 did: event.did, 113 rkey: event.commit.rkey, 114 cid: event.commit.cid, 115 text: record.text, 116 forumUri: record.forum?.forum.uri ?? null, 117 boardUri: record.board?.board.uri ?? null, ··· 143 144 return { 145 cid: event.commit.cid, 146 text: record.text, 147 forumUri: record.forum?.forum.uri ?? null, 148 boardUri: record.board?.board.uri ?? null,
··· 112 did: event.did, 113 rkey: event.commit.rkey, 114 cid: event.commit.cid, 115 + title: record.title ?? null, 116 text: record.text, 117 forumUri: record.forum?.forum.uri ?? null, 118 boardUri: record.board?.board.uri ?? null, ··· 144 145 return { 146 cid: event.commit.cid, 147 + title: record.title ?? null, 148 text: record.text, 149 forumUri: record.forum?.forum.uri ?? null, 150 boardUri: record.board?.board.uri ?? null,
+57
apps/appview/src/middleware/permissions.ts
··· 3 import type { Variables } from "../types.js"; 4 import { memberships, roles } from "@atbb/db"; 5 import { eq, and } from "drizzle-orm"; 6 7 /** 8 * Check if a user has a specific permission. ··· 255 error: "Insufficient role", 256 required: minRole 257 }, 403); 258 } 259 260 await next();
··· 3 import type { Variables } from "../types.js"; 4 import { memberships, roles } from "@atbb/db"; 5 import { eq, and } from "drizzle-orm"; 6 + import { getActiveBans } from "../routes/helpers.js"; 7 + import { isProgrammingError, isDatabaseError } from "../lib/errors.js"; 8 9 /** 10 * Check if a user has a specific permission. ··· 257 error: "Insufficient role", 258 required: minRole 259 }, 403); 260 + } 261 + 262 + await next(); 263 + }; 264 + } 265 + 266 + /** 267 + * Require the authenticated user to not be banned. 268 + * 269 + * Must run after requireAuth (relies on c.get("user") being set). 270 + * Returns 403 if the user is banned. 271 + * Fails closed on errors (denies access on DB failure). 272 + */ 273 + export function requireNotBanned(ctx: AppContext) { 274 + return async (c: Context<{ Variables: Variables }>, next: Next) => { 275 + const user = c.get("user"); 276 + 277 + if (!user) { 278 + return c.json({ error: "Authentication required" }, 401); 279 + } 280 + 281 + try { 282 + const bannedUsers = await getActiveBans(ctx.db, [user.did]); 283 + if (bannedUsers.has(user.did)) { 284 + return c.json({ error: "You are banned from this forum" }, 403); 285 + } 286 + } catch (error) { 287 + if (isProgrammingError(error)) { 288 + console.error("CRITICAL: Programming error in ban check", { 289 + operation: "requireNotBanned", 290 + userId: user.did, 291 + error: error instanceof Error ? error.message : String(error), 292 + stack: error instanceof Error ? error.stack : undefined, 293 + }); 294 + throw error; 295 + } 296 + 297 + console.error("Failed to check ban status", { 298 + operation: "requireNotBanned", 299 + userId: user.did, 300 + error: error instanceof Error ? error.message : String(error), 301 + }); 302 + 303 + if (error instanceof Error && isDatabaseError(error)) { 304 + return c.json( 305 + { error: "Database temporarily unavailable. Please try again later." }, 306 + 503 307 + ); 308 + } 309 + 310 + // Unexpected errors - fail closed 311 + return c.json( 312 + { error: "Unable to verify permissions. Please try again later." }, 313 + 500 314 + ); 315 } 316 317 await next();
+71
apps/appview/src/routes/__tests__/helpers.test.ts
··· 2 import { isProgrammingError, isNetworkError } from "../../lib/errors.js"; 3 import { 4 validatePostText, 5 getForumByUri, 6 getPostsByIds, 7 validateReplyParent, ··· 93 }); 94 }); 95 96 describe("getForumByUri", () => { 97 let ctx: TestContext; 98 ··· 264 did: "did:plc:topic-author", 265 rkey: "3lbk7topic", 266 cid: "bafytopic", 267 text: "Hello, forum!", 268 forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 269 boardUri: null, ··· 283 did: "did:plc:reply-author", 284 rkey: "3lbk8reply", 285 cid: "bafyreply", 286 text: "Great topic!", 287 forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 288 boardUri: null, ··· 314 id: "1", 315 did: "did:plc:topic-author", 316 rkey: "3lbk7topic", 317 text: "Hello, forum!", 318 forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 319 boardUri: null, ··· 337 id: "2", 338 did: "did:plc:reply-author", 339 rkey: "3lbk8reply", 340 text: "Great topic!", 341 forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 342 boardUri: null,
··· 2 import { isProgrammingError, isNetworkError } from "../../lib/errors.js"; 3 import { 4 validatePostText, 5 + validateTopicTitle, 6 getForumByUri, 7 getPostsByIds, 8 validateReplyParent, ··· 94 }); 95 }); 96 97 + describe("validateTopicTitle", () => { 98 + it("accepts title with exactly 120 graphemes", () => { 99 + const title = "a".repeat(120); 100 + const result = validateTopicTitle(title); 101 + expect(result.valid).toBe(true); 102 + expect(result.trimmed).toBe(title); 103 + }); 104 + 105 + it("rejects title with 121 graphemes", () => { 106 + const title = "a".repeat(121); 107 + const result = validateTopicTitle(title); 108 + expect(result.valid).toBe(false); 109 + expect(result.error).toBe("Title must be 120 characters or less"); 110 + }); 111 + 112 + it("accepts exactly 120 emoji (tests actual grapheme counting)", () => { 113 + // Each 👋 is a single grapheme but multiple code units 114 + const title = "👋".repeat(120); 115 + const result = validateTopicTitle(title); 116 + expect(result.valid).toBe(true); 117 + }); 118 + 119 + it("rejects 121 emoji", () => { 120 + const title = "👋".repeat(121); 121 + const result = validateTopicTitle(title); 122 + expect(result.valid).toBe(false); 123 + expect(result.error).toBe("Title must be 120 characters or less"); 124 + }); 125 + 126 + it("rejects empty title after trimming", () => { 127 + const result = validateTopicTitle(" "); 128 + expect(result.valid).toBe(false); 129 + expect(result.error).toBe("Title cannot be empty"); 130 + }); 131 + 132 + it("trims whitespace before validation and returns trimmed value", () => { 133 + const result = validateTopicTitle(" My Topic "); 134 + expect(result.valid).toBe(true); 135 + expect(result.trimmed).toBe("My Topic"); 136 + }); 137 + 138 + it("rejects non-string input (null)", () => { 139 + const result = validateTopicTitle(null as any); 140 + expect(result.valid).toBe(false); 141 + expect(result.error).toBe("Title is required and must be a string"); 142 + }); 143 + 144 + it("rejects non-string input (undefined)", () => { 145 + const result = validateTopicTitle(undefined as any); 146 + expect(result.valid).toBe(false); 147 + expect(result.error).toBe("Title is required and must be a string"); 148 + }); 149 + 150 + it("rejects non-string input (number)", () => { 151 + const result = validateTopicTitle(42 as any); 152 + expect(result.valid).toBe(false); 153 + expect(result.error).toBe("Title is required and must be a string"); 154 + }); 155 + 156 + it("rejects non-string input (object)", () => { 157 + const result = validateTopicTitle({ title: "hello" } as any); 158 + expect(result.valid).toBe(false); 159 + expect(result.error).toBe("Title is required and must be a string"); 160 + }); 161 + }); 162 + 163 describe("getForumByUri", () => { 164 let ctx: TestContext; 165 ··· 331 did: "did:plc:topic-author", 332 rkey: "3lbk7topic", 333 cid: "bafytopic", 334 + title: "Hello World", 335 text: "Hello, forum!", 336 forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 337 boardUri: null, ··· 351 did: "did:plc:reply-author", 352 rkey: "3lbk8reply", 353 cid: "bafyreply", 354 + title: null, 355 text: "Great topic!", 356 forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 357 boardUri: null, ··· 383 id: "1", 384 did: "did:plc:topic-author", 385 rkey: "3lbk7topic", 386 + title: "Hello World", 387 text: "Hello, forum!", 388 forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 389 boardUri: null, ··· 407 id: "2", 408 did: "did:plc:reply-author", 409 rkey: "3lbk8reply", 410 + title: null, 411 text: "Great topic!", 412 forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 413 boardUri: null,
+12 -9
apps/appview/src/routes/__tests__/posts.test.ts
··· 16 }), 17 })); 18 19 - vi.mock("../../middleware/permissions.js", () => ({ 20 - requirePermission: vi.fn(() => async (c: any, next: any) => { 21 - // User already set by requireAuth mock 22 - await next(); 23 - }), 24 - })); 25 26 // Import after mocking 27 const { createPostsRoutes } = await import("../posts.js"); ··· 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 }) ··· 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 }) ··· 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),
··· 16 }), 17 })); 18 19 + vi.mock("../../middleware/permissions.js", async (importOriginal) => { 20 + const actual = await importOriginal<typeof import("../../middleware/permissions.js")>(); 21 + return { 22 + ...actual, // Keep requireNotBanned real so ban enforcement tests work 23 + requirePermission: vi.fn(() => async (c: any, next: any) => { 24 + await next(); 25 + }), 26 + }; 27 + }); 28 29 // Import after mocking 30 const { createPostsRoutes } = await import("../posts.js"); ··· 519 expect(consoleErrorSpy).toHaveBeenCalledWith( 520 "Failed to check ban status", 521 expect.objectContaining({ 522 + operation: "requireNotBanned", 523 userId: mockUser.did, 524 error: "Database connection lost", 525 }) ··· 553 expect(consoleErrorSpy).toHaveBeenCalledWith( 554 "Failed to check ban status", 555 expect.objectContaining({ 556 + operation: "requireNotBanned", 557 userId: mockUser.did, 558 error: "Unexpected internal error", 559 }) ··· 590 expect(consoleErrorSpy).toHaveBeenCalledWith( 591 "CRITICAL: Programming error in ban check", 592 expect.objectContaining({ 593 + operation: "requireNotBanned", 594 userId: mockUser.did, 595 error: "Cannot read property 'has' of undefined", 596 stack: expect.any(String),
+150 -27
apps/appview/src/routes/__tests__/topics.test.ts
··· 17 }), 18 })); 19 20 - vi.mock("../../middleware/permissions.js", () => ({ 21 - requirePermission: vi.fn(() => async (c: any, next: any) => { 22 - // User already set by requireAuth mock 23 - await next(); 24 - }), 25 - })); 26 27 // Import after mocking 28 const { createTopicsRoutes } = await import("../topics.js"); ··· 54 const body = await res.json(); 55 expect(body).toHaveProperty("error", "Invalid topic ID format"); 56 }); 57 }); 58 59 describe("POST /api/topics", () => { ··· 137 await ctx.cleanup(); 138 }); 139 140 - it("creates topic with valid text", async () => { 141 const res = await app.request("/api/topics", { 142 method: "POST", 143 headers: { "Content-Type": "application/json" }, 144 - body: JSON.stringify({ text: "Hello, atBB!", boardUri: testBoardUri }), 145 }); 146 147 expect(res.status).toBe(201); ··· 151 expect(data.rkey).toBeTruthy(); 152 }); 153 154 it("returns 400 for empty text", async () => { 155 const res = await app.request("/api/topics", { 156 method: "POST", 157 headers: { "Content-Type": "application/json" }, 158 - body: JSON.stringify({ text: " ", boardUri: testBoardUri }), 159 }); 160 161 expect(res.status).toBe(400); ··· 167 const res = await app.request("/api/topics", { 168 method: "POST", 169 headers: { "Content-Type": "application/json" }, 170 - body: JSON.stringify({ text: "a".repeat(301), boardUri: testBoardUri }), 171 }); 172 173 expect(res.status).toBe(400); ··· 175 expect(data.error).toContain("300 characters"); 176 }); 177 178 - it("uses default forum URI when not provided", async () => { 179 const res = await app.request("/api/topics", { 180 method: "POST", 181 headers: { "Content-Type": "application/json" }, 182 - body: JSON.stringify({ text: "Test topic", boardUri: testBoardUri }), 183 }); 184 185 expect(res.status).toBe(201); 186 - // Verify putRecord was called with correct forum ref 187 expect(mockPutRecord).toHaveBeenCalledWith( 188 expect.objectContaining({ 189 record: expect.objectContaining({ 190 forum: expect.objectContaining({ 191 forum: expect.objectContaining({ 192 uri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, ··· 202 const res = await app.request("/api/topics", { 203 method: "POST", 204 headers: { "Content-Type": "application/json" }, 205 - body: JSON.stringify({}), // No text field 206 }); 207 208 expect(res.status).toBe(400); ··· 214 const res = await app.request("/api/topics", { 215 method: "POST", 216 headers: { "Content-Type": "application/json" }, 217 - body: JSON.stringify({ text: 123 }), 218 }); 219 220 expect(res.status).toBe(400); ··· 226 const res = await app.request("/api/topics", { 227 method: "POST", 228 headers: { "Content-Type": "application/json" }, 229 - body: JSON.stringify({ text: null }), 230 }); 231 232 expect(res.status).toBe(400); ··· 254 const res = await app.request("/api/topics", { 255 method: "POST", 256 headers: { "Content-Type": "application/json" }, 257 - body: JSON.stringify({ text: "Test topic", boardUri: testBoardUri }), 258 }); 259 260 expect(res.status).toBe(503); ··· 268 const res = await app.request("/api/topics", { 269 method: "POST", 270 headers: { "Content-Type": "application/json" }, 271 - body: JSON.stringify({ text: "Test topic", boardUri: testBoardUri }), 272 }); 273 274 expect(res.status).toBe(503); ··· 282 const res = await app.request("/api/topics", { 283 method: "POST", 284 headers: { "Content-Type": "application/json" }, 285 - body: JSON.stringify({ text: "Test topic", boardUri: testBoardUri }), 286 }); 287 288 expect(res.status).toBe(503); ··· 297 const res = await app.request("/api/topics", { 298 method: "POST", 299 headers: { "Content-Type": "application/json" }, 300 - body: JSON.stringify({ text: "Test topic", boardUri: testBoardUri }), 301 }); 302 303 expect(res.status).toBe(500); ··· 305 expect(data.error).toContain("Failed to create topic"); 306 }); 307 308 - it("returns 503 for database connection errors", async () => { 309 - mockPutRecord.mockRejectedValueOnce(new Error("Database connection lost")); 310 311 const res = await app.request("/api/topics", { 312 method: "POST", 313 headers: { "Content-Type": "application/json" }, 314 - body: JSON.stringify({ text: "Test topic", boardUri: testBoardUri }), 315 }); 316 317 expect(res.status).toBe(503); 318 const data = await res.json(); 319 expect(data.error).toContain("Database temporarily unavailable"); 320 }); 321 322 it("returns 500 for unexpected non-network/non-database errors", async () => { ··· 325 const res = await app.request("/api/topics", { 326 method: "POST", 327 headers: { "Content-Type": "application/json" }, 328 - body: JSON.stringify({ text: "Test topic", boardUri: testBoardUri }), 329 }); 330 331 expect(res.status).toBe(500); ··· 372 "Content-Type": "application/json", 373 }, 374 body: JSON.stringify({ 375 text: "Test topic with board", 376 boardUri, 377 }), ··· 407 "Content-Type": "application/json", 408 }, 409 body: JSON.stringify({ 410 text: "Topic without board", 411 }), 412 }); ··· 423 "Content-Type": "application/json", 424 }, 425 body: JSON.stringify({ 426 text: "Topic with invalid board", 427 boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/nonexistent`, 428 }), ··· 440 "Content-Type": "application/json", 441 }, 442 body: JSON.stringify({ 443 text: "Test topic", 444 boardUri: "not-a-valid-uri", 445 }), ··· 457 "Content-Type": "application/json", 458 }, 459 body: JSON.stringify({ 460 text: "Test topic", 461 boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/test-cat`, 462 }), ··· 474 "Content-Type": "application/json", 475 }, 476 body: JSON.stringify({ 477 text: "Test topic", 478 boardUri: "at://did:plc:different-forum/space.atbb.forum.board/some-board", 479 }), ··· 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 }), ··· 800 method: "POST", 801 headers: { "Content-Type": "application/json" }, 802 body: JSON.stringify({ 803 text: "Attempt from banned user", 804 boardUri: testBoardUri, 805 }), ··· 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 }), ··· 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 }) ··· 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 }), ··· 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 }) ··· 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 }), ··· 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),
··· 17 }), 18 })); 19 20 + vi.mock("../../middleware/permissions.js", async (importOriginal) => { 21 + const actual = await importOriginal<typeof import("../../middleware/permissions.js")>(); 22 + return { 23 + ...actual, // Keep requireNotBanned real so ban enforcement tests work 24 + requirePermission: vi.fn(() => async (c: any, next: any) => { 25 + await next(); 26 + }), 27 + }; 28 + }); 29 30 // Import after mocking 31 const { createTopicsRoutes } = await import("../topics.js"); ··· 57 const body = await res.json(); 58 expect(body).toHaveProperty("error", "Invalid topic ID format"); 59 }); 60 + 61 + it("returns title field in topic response (GET round-trip)", async () => { 62 + // Insert user and topic with a title directly in the DB 63 + await ctx.db.insert(users).values({ 64 + did: "did:plc:title-roundtrip-user", 65 + handle: "titleuser.test", 66 + indexedAt: new Date(), 67 + }).onConflictDoNothing(); 68 + 69 + const [topic] = await ctx.db.insert(posts).values({ 70 + did: "did:plc:title-roundtrip-user", 71 + rkey: "3lbk7title", 72 + cid: "bafytitle", 73 + title: "My Topic Title", 74 + text: "Topic body text", 75 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 76 + createdAt: new Date(), 77 + indexedAt: new Date(), 78 + deleted: false, 79 + }).returning(); 80 + 81 + const res = await app.request(`/api/topics/${topic.id.toString()}`); 82 + 83 + expect(res.status).toBe(200); 84 + const data = await res.json(); 85 + expect(data.post.title).toBe("My Topic Title"); 86 + }); 87 + 88 + it("returns null title for pre-migration topics (backward compat)", async () => { 89 + // Insert user and topic with null title (simulates pre-migration data) 90 + await ctx.db.insert(users).values({ 91 + did: "did:plc:null-title-user", 92 + handle: "nulltitleuser.test", 93 + indexedAt: new Date(), 94 + }).onConflictDoNothing(); 95 + 96 + const [topic] = await ctx.db.insert(posts).values({ 97 + did: "did:plc:null-title-user", 98 + rkey: "3lbk7notitle", 99 + cid: "bafynotitle", 100 + title: null, 101 + text: "Legacy topic without a title", 102 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 103 + createdAt: new Date(), 104 + indexedAt: new Date(), 105 + deleted: false, 106 + }).returning(); 107 + 108 + const res = await app.request(`/api/topics/${topic.id.toString()}`); 109 + 110 + expect(res.status).toBe(200); 111 + const data = await res.json(); 112 + expect(data.post.title).toBeNull(); 113 + }); 114 }); 115 116 describe("POST /api/topics", () => { ··· 194 await ctx.cleanup(); 195 }); 196 197 + it("creates topic with valid title and text", async () => { 198 const res = await app.request("/api/topics", { 199 method: "POST", 200 headers: { "Content-Type": "application/json" }, 201 + body: JSON.stringify({ title: "Hello World", text: "Hello, atBB!", boardUri: testBoardUri }), 202 }); 203 204 expect(res.status).toBe(201); ··· 208 expect(data.rkey).toBeTruthy(); 209 }); 210 211 + it("returns 400 when title is missing", async () => { 212 + const res = await app.request("/api/topics", { 213 + method: "POST", 214 + headers: { "Content-Type": "application/json" }, 215 + body: JSON.stringify({ text: "Hello, atBB!", boardUri: testBoardUri }), 216 + }); 217 + 218 + expect(res.status).toBe(400); 219 + const data = await res.json(); 220 + expect(data.error).toContain("Title is required"); 221 + }); 222 + 223 + it("returns 400 for empty title", async () => { 224 + const res = await app.request("/api/topics", { 225 + method: "POST", 226 + headers: { "Content-Type": "application/json" }, 227 + body: JSON.stringify({ title: " ", text: "Hello, atBB!", boardUri: testBoardUri }), 228 + }); 229 + 230 + expect(res.status).toBe(400); 231 + const data = await res.json(); 232 + expect(data.error).toContain("empty"); 233 + }); 234 + 235 + it("returns 400 for non-string title", async () => { 236 + const res = await app.request("/api/topics", { 237 + method: "POST", 238 + headers: { "Content-Type": "application/json" }, 239 + body: JSON.stringify({ title: 123, text: "Hello, atBB!", boardUri: testBoardUri }), 240 + }); 241 + 242 + expect(res.status).toBe(400); 243 + const data = await res.json(); 244 + expect(data.error).toContain("must be a string"); 245 + }); 246 + 247 + it("returns 400 for title exceeding 120 graphemes", async () => { 248 + const res = await app.request("/api/topics", { 249 + method: "POST", 250 + headers: { "Content-Type": "application/json" }, 251 + body: JSON.stringify({ title: "a".repeat(121), text: "Hello, atBB!", boardUri: testBoardUri }), 252 + }); 253 + 254 + expect(res.status).toBe(400); 255 + const data = await res.json(); 256 + expect(data.error).toContain("120 characters"); 257 + }); 258 + 259 it("returns 400 for empty text", async () => { 260 const res = await app.request("/api/topics", { 261 method: "POST", 262 headers: { "Content-Type": "application/json" }, 263 + body: JSON.stringify({ title: "Test Topic", text: " ", boardUri: testBoardUri }), 264 }); 265 266 expect(res.status).toBe(400); ··· 272 const res = await app.request("/api/topics", { 273 method: "POST", 274 headers: { "Content-Type": "application/json" }, 275 + body: JSON.stringify({ title: "Test Topic", text: "a".repeat(301), boardUri: testBoardUri }), 276 }); 277 278 expect(res.status).toBe(400); ··· 280 expect(data.error).toContain("300 characters"); 281 }); 282 283 + it("includes title in PDS record and uses default forum URI", async () => { 284 const res = await app.request("/api/topics", { 285 method: "POST", 286 headers: { "Content-Type": "application/json" }, 287 + body: JSON.stringify({ title: "My Topic Title", text: "Test topic", boardUri: testBoardUri }), 288 }); 289 290 expect(res.status).toBe(201); 291 + // Verify putRecord was called with correct title and forum ref 292 expect(mockPutRecord).toHaveBeenCalledWith( 293 expect.objectContaining({ 294 record: expect.objectContaining({ 295 + title: "My Topic Title", 296 forum: expect.objectContaining({ 297 forum: expect.objectContaining({ 298 uri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, ··· 308 const res = await app.request("/api/topics", { 309 method: "POST", 310 headers: { "Content-Type": "application/json" }, 311 + body: JSON.stringify({ title: "Test Topic" }), // No text field 312 }); 313 314 expect(res.status).toBe(400); ··· 320 const res = await app.request("/api/topics", { 321 method: "POST", 322 headers: { "Content-Type": "application/json" }, 323 + body: JSON.stringify({ title: "Test Topic", text: 123 }), 324 }); 325 326 expect(res.status).toBe(400); ··· 332 const res = await app.request("/api/topics", { 333 method: "POST", 334 headers: { "Content-Type": "application/json" }, 335 + body: JSON.stringify({ title: "Test Topic", text: null }), 336 }); 337 338 expect(res.status).toBe(400); ··· 360 const res = await app.request("/api/topics", { 361 method: "POST", 362 headers: { "Content-Type": "application/json" }, 363 + body: JSON.stringify({ title: "Test Topic", text: "Test topic", boardUri: testBoardUri }), 364 }); 365 366 expect(res.status).toBe(503); ··· 374 const res = await app.request("/api/topics", { 375 method: "POST", 376 headers: { "Content-Type": "application/json" }, 377 + body: JSON.stringify({ title: "Test Topic", text: "Test topic", boardUri: testBoardUri }), 378 }); 379 380 expect(res.status).toBe(503); ··· 388 const res = await app.request("/api/topics", { 389 method: "POST", 390 headers: { "Content-Type": "application/json" }, 391 + body: JSON.stringify({ title: "Test Topic", text: "Test topic", boardUri: testBoardUri }), 392 }); 393 394 expect(res.status).toBe(503); ··· 403 const res = await app.request("/api/topics", { 404 method: "POST", 405 headers: { "Content-Type": "application/json" }, 406 + body: JSON.stringify({ title: "Test Topic", text: "Test topic", boardUri: testBoardUri }), 407 }); 408 409 expect(res.status).toBe(500); ··· 411 expect(data.error).toContain("Failed to create topic"); 412 }); 413 414 + it("returns 503 when database lookup fails before PDS write", async () => { 415 + // DB lookup errors (getForumByUri/getBoardByUri) are handled in their own try block 416 + // and must not bleed into the PDS write error handler 417 + const helpers = await import("../helpers.js"); 418 + const getForumByUriSpy = vi.spyOn(helpers, "getForumByUri"); 419 + getForumByUriSpy.mockRejectedValueOnce(new Error("Database connection lost")); 420 421 const res = await app.request("/api/topics", { 422 method: "POST", 423 headers: { "Content-Type": "application/json" }, 424 + body: JSON.stringify({ title: "Test Topic", text: "Test topic", boardUri: testBoardUri }), 425 }); 426 427 expect(res.status).toBe(503); 428 const data = await res.json(); 429 expect(data.error).toContain("Database temporarily unavailable"); 430 + 431 + getForumByUriSpy.mockRestore(); 432 }); 433 434 it("returns 500 for unexpected non-network/non-database errors", async () => { ··· 437 const res = await app.request("/api/topics", { 438 method: "POST", 439 headers: { "Content-Type": "application/json" }, 440 + body: JSON.stringify({ title: "Test Topic", text: "Test topic", boardUri: testBoardUri }), 441 }); 442 443 expect(res.status).toBe(500); ··· 484 "Content-Type": "application/json", 485 }, 486 body: JSON.stringify({ 487 + title: "Test topic with board", 488 text: "Test topic with board", 489 boardUri, 490 }), ··· 520 "Content-Type": "application/json", 521 }, 522 body: JSON.stringify({ 523 + title: "Topic without board", 524 text: "Topic without board", 525 }), 526 }); ··· 537 "Content-Type": "application/json", 538 }, 539 body: JSON.stringify({ 540 + title: "Topic with invalid board", 541 text: "Topic with invalid board", 542 boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/nonexistent`, 543 }), ··· 555 "Content-Type": "application/json", 556 }, 557 body: JSON.stringify({ 558 + title: "Test Topic", 559 text: "Test topic", 560 boardUri: "not-a-valid-uri", 561 }), ··· 573 "Content-Type": "application/json", 574 }, 575 body: JSON.stringify({ 576 + title: "Test Topic", 577 text: "Test topic", 578 boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/test-cat`, 579 }), ··· 591 "Content-Type": "application/json", 592 }, 593 body: JSON.stringify({ 594 + title: "Test Topic", 595 text: "Test topic", 596 boardUri: "at://did:plc:different-forum/space.atbb.forum.board/some-board", 597 }), ··· 853 method: "POST", 854 headers: { "Content-Type": "application/json" }, 855 body: JSON.stringify({ 856 + title: "Non-banned user topic", 857 text: "New topic from non-banned user", 858 boardUri: testBoardUri, 859 }), ··· 919 method: "POST", 920 headers: { "Content-Type": "application/json" }, 921 body: JSON.stringify({ 922 + title: "Banned user attempt", 923 text: "Attempt from banned user", 924 boardUri: testBoardUri, 925 }), ··· 945 method: "POST", 946 headers: { "Content-Type": "application/json" }, 947 body: JSON.stringify({ 948 + title: "DB Error Topic", 949 text: "Topic attempt during DB error", 950 boardUri: "at://did:plc:forum/space.atbb.forum.board/test", 951 }), ··· 958 expect(consoleErrorSpy).toHaveBeenCalledWith( 959 "Failed to check ban status", 960 expect.objectContaining({ 961 + operation: "requireNotBanned", 962 userId: mockUser.did, 963 error: "Database connection lost", 964 }) ··· 983 method: "POST", 984 headers: { "Content-Type": "application/json" }, 985 body: JSON.stringify({ 986 + title: "Error Topic", 987 text: "Topic attempt during error", 988 boardUri: "at://did:plc:forum/space.atbb.forum.board/test", 989 }), ··· 996 expect(consoleErrorSpy).toHaveBeenCalledWith( 997 "Failed to check ban status", 998 expect.objectContaining({ 999 + operation: "requireNotBanned", 1000 userId: mockUser.did, 1001 error: "Unexpected internal error", 1002 }) ··· 1026 method: "POST", 1027 headers: { "Content-Type": "application/json" }, 1028 body: JSON.stringify({ 1029 + title: "Programming Error Topic", 1030 text: "Topic with programming error", 1031 boardUri: "at://did:plc:forum/space.atbb.forum.board/test", 1032 }), ··· 1039 expect(consoleErrorSpy).toHaveBeenCalledWith( 1040 "CRITICAL: Programming error in ban check", 1041 expect.objectContaining({ 1042 + operation: "requireNotBanned", 1043 userId: mockUser.did, 1044 error: "Cannot read property 'includes' of undefined", 1045 stack: expect.any(String),
+33
apps/appview/src/routes/helpers.ts
··· 93 } 94 95 /** 96 * Look up forum by AT-URI. 97 * Returns null if forum doesn't exist. 98 * ··· 200 * "id": "1234", // BigInt → string 201 * "did": "did:plc:...", 202 * "rkey": "3lbk7...", 203 * "text": "Post content", 204 * "forumUri": "at://..." | null, 205 * "boardUri": "at://..." | null, ··· 215 id: serializeBigInt(post.id), 216 did: post.did, 217 rkey: post.rkey, 218 text: post.text, 219 forumUri: post.forumUri ?? null, 220 boardUri: post.boardUri ?? null,
··· 93 } 94 95 /** 96 + * Validate topic title according to lexicon constraints. 97 + * - Max 120 graphemes (user-perceived characters) 98 + * - Non-empty after trimming whitespace 99 + */ 100 + export function validateTopicTitle(title: unknown): { 101 + valid: boolean; 102 + trimmed?: string; 103 + error?: string; 104 + } { 105 + if (typeof title !== "string") { 106 + return { valid: false, error: "Title is required and must be a string" }; 107 + } 108 + 109 + const trimmed = title.trim(); 110 + 111 + if (trimmed.length === 0) { 112 + return { valid: false, error: "Title cannot be empty" }; 113 + } 114 + 115 + const graphemeLength = new UnicodeString(trimmed).graphemeLength; 116 + if (graphemeLength > 120) { 117 + return { 118 + valid: false, 119 + error: "Title must be 120 characters or less", 120 + }; 121 + } 122 + 123 + return { valid: true, trimmed }; 124 + } 125 + 126 + /** 127 * Look up forum by AT-URI. 128 * Returns null if forum doesn't exist. 129 * ··· 231 * "id": "1234", // BigInt → string 232 * "did": "did:plc:...", 233 * "rkey": "3lbk7...", 234 + * "title": "Topic title" | null, 235 * "text": "Post content", 236 * "forumUri": "at://..." | null, 237 * "boardUri": "at://..." | null, ··· 247 id: serializeBigInt(post.id), 248 did: post.did, 249 rkey: post.rkey, 250 + title: post.title ?? null, 251 text: post.text, 252 forumUri: post.forumUri ?? null, 253 boardUri: post.boardUri ?? null,
+3 -42
apps/appview/src/routes/posts.ts
··· 3 import type { AppContext } from "../lib/app-context.js"; 4 import type { Variables } from "../types.js"; 5 import { requireAuth } from "../middleware/auth.js"; 6 - import { requirePermission } from "../middleware/permissions.js"; 7 import { isProgrammingError, isNetworkError, isDatabaseError } from "../lib/errors.js"; 8 import { 9 validatePostText, 10 parseBigIntParam, 11 getPostsByIds, 12 validateReplyParent, 13 - getActiveBans, 14 getTopicModStatus, 15 } from "./helpers.js"; 16 17 export function createPostsRoutes(ctx: AppContext) { 18 - return new Hono<{ Variables: Variables }>().post("/", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.createPosts"), async (c) => { 19 const user = c.get("user")!; 20 21 // Parse and validate request body ··· 44 error: "Invalid post ID format. IDs must be numeric strings.", 45 }, 46 400 47 - ); 48 - } 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
··· 3 import type { AppContext } from "../lib/app-context.js"; 4 import type { Variables } from "../types.js"; 5 import { requireAuth } from "../middleware/auth.js"; 6 + import { requirePermission, requireNotBanned } from "../middleware/permissions.js"; 7 import { isProgrammingError, isNetworkError, isDatabaseError } from "../lib/errors.js"; 8 import { 9 validatePostText, 10 parseBigIntParam, 11 getPostsByIds, 12 validateReplyParent, 13 getTopicModStatus, 14 } from "./helpers.js"; 15 16 export function createPostsRoutes(ctx: AppContext) { 17 + return new Hono<{ Variables: Variables }>().post("/", requireAuth(ctx), requireNotBanned(ctx), requirePermission(ctx, "space.atbb.permission.createPosts"), async (c) => { 18 + // user is guaranteed to exist after requireAuth, requireNotBanned, and requirePermission middleware 19 const user = c.get("user")!; 20 21 // Parse and validate request body ··· 44 error: "Invalid post ID format. IDs must be numeric strings.", 45 }, 46 400 47 ); 48 } 49
+79 -67
apps/appview/src/routes/topics.ts
··· 5 import { eq, and, asc } from "drizzle-orm"; 6 import { TID } from "@atproto/common-web"; 7 import { requireAuth } from "../middleware/auth.js"; 8 - import { requirePermission } from "../middleware/permissions.js"; 9 import { parseAtUri } from "../lib/at-uri.js"; 10 - import { isProgrammingError, isNetworkError, isDatabaseError } from "../lib/errors.js"; 11 import { 12 parseBigIntParam, 13 serializePost, 14 validatePostText, 15 getForumByUri, 16 getBoardByUri, 17 getActiveBans, ··· 133 ); 134 } 135 }) 136 - .post("/", requireAuth(ctx), requirePermission(ctx, "space.atbb.permission.createTopics"), async (c) => { 137 - // user is guaranteed to exist after requireAuth and requirePermission middleware 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 - } 178 - 179 // Parse and validate request body 180 let body: any; 181 try { ··· 184 return c.json({ error: "Invalid JSON in request body" }, 400); 185 } 186 187 - const { text, boardUri } = body; 188 189 // Validate text 190 const validation = validatePostText(text); ··· 213 return c.json({ error: "boardUri must belong to this forum" }, 400); 214 } 215 216 - try { 217 - // Always use the configured singleton forum 218 - const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 219 220 - // Look up forum to get CID 221 - const forum = await getForumByUri(ctx.db, forumUri); 222 if (!forum) { 223 return c.json({ error: "Forum not found" }, 404); 224 } 225 226 - // Look up board to get CID 227 - const board = await getBoardByUri(ctx.db, boardUri); 228 if (!board) { 229 return c.json({ error: "Board not found" }, 404); 230 } 231 232 - // Generate TID for rkey 233 - const rkey = TID.nextStr(); 234 235 - // Write to user's PDS 236 const result = await user.agent.com.atproto.repo.putRecord({ 237 repo: user.did, 238 collection: "space.atbb.post", 239 rkey, 240 record: { 241 $type: "space.atbb.post", 242 text: validation.trimmed!, 243 forum: { 244 - forum: { uri: forumUri, cid: forum.cid }, 245 }, 246 board: { 247 - board: { uri: boardUri, cid: board.cid }, 248 }, 249 createdAt: new Date().toISOString(), 250 }, ··· 276 error: error instanceof Error ? error.message : String(error), 277 }); 278 279 - // Distinguish network errors from server errors 280 if (error instanceof Error && isNetworkError(error)) { 281 return c.json( 282 { 283 error: "Unable to reach your PDS. Please try again later.", 284 - }, 285 - 503 286 - ); 287 - } 288 - 289 - // Database connection errors - temporary, user should retry 290 - if (error instanceof Error && isDatabaseError(error)) { 291 - return c.json( 292 - { 293 - error: "Database temporarily unavailable. Please try again later.", 294 }, 295 503 296 );
··· 5 import { eq, and, asc } from "drizzle-orm"; 6 import { TID } from "@atproto/common-web"; 7 import { requireAuth } from "../middleware/auth.js"; 8 + import { requirePermission, requireNotBanned } from "../middleware/permissions.js"; 9 import { parseAtUri } from "../lib/at-uri.js"; 10 + import { isProgrammingError, isNetworkError } from "../lib/errors.js"; 11 import { 12 parseBigIntParam, 13 serializePost, 14 validatePostText, 15 + validateTopicTitle, 16 getForumByUri, 17 getBoardByUri, 18 getActiveBans, ··· 134 ); 135 } 136 }) 137 + .post("/", requireAuth(ctx), requireNotBanned(ctx), requirePermission(ctx, "space.atbb.permission.createTopics"), async (c) => { 138 + // user is guaranteed to exist after requireAuth, requireNotBanned, and requirePermission middleware 139 const user = c.get("user")!; 140 141 // Parse and validate request body 142 let body: any; 143 try { ··· 146 return c.json({ error: "Invalid JSON in request body" }, 400); 147 } 148 149 + const { title, text, boardUri } = body; 150 + 151 + // Validate title 152 + // Note: the lexicon marks `title` as optional (required only for topics, not replies). 153 + // AT Protocol record schemas cannot express per-use-case requirements. 154 + // AppView enforces it as mandatory here for all topic creation requests. 155 + const titleValidation = validateTopicTitle(title); 156 + if (!titleValidation.valid) { 157 + return c.json({ error: titleValidation.error }, 400); 158 + } 159 160 // Validate text 161 const validation = validatePostText(text); ··· 184 return c.json({ error: "boardUri must belong to this forum" }, 400); 185 } 186 187 + // Always use the configured singleton forum 188 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 189 190 + // Phase 1a: Look up forum — separated from the PDS write so a database 191 + // error cannot be misclassified as a PDS failure. 192 + let forum: { did: string; rkey: string; cid: string } | null = null; 193 + 194 + try { 195 + forum = await getForumByUri(ctx.db, forumUri); 196 if (!forum) { 197 return c.json({ error: "Forum not found" }, 404); 198 } 199 + } catch (error) { 200 + if (isProgrammingError(error)) { 201 + console.error("CRITICAL: Programming error in POST /api/topics (forum lookup)", { 202 + operation: "POST /api/topics", 203 + userId: user.did, 204 + error: error instanceof Error ? error.message : String(error), 205 + stack: error instanceof Error ? error.stack : undefined, 206 + }); 207 + throw error; 208 + } 209 210 + console.error("Failed to look up forum record before writing topic to PDS", { 211 + operation: "POST /api/topics", 212 + userId: user.did, 213 + error: error instanceof Error ? error.message : String(error), 214 + }); 215 + 216 + return c.json( 217 + { error: "Database temporarily unavailable. Please try again later." }, 218 + 503 219 + ); 220 + } 221 + 222 + // Phase 1b: Look up board 223 + let board: { cid: string } | null = null; 224 + 225 + try { 226 + board = await getBoardByUri(ctx.db, boardUri); 227 if (!board) { 228 return c.json({ error: "Board not found" }, 404); 229 } 230 + } catch (error) { 231 + if (isProgrammingError(error)) { 232 + console.error("CRITICAL: Programming error in POST /api/topics (board lookup)", { 233 + operation: "POST /api/topics", 234 + userId: user.did, 235 + error: error instanceof Error ? error.message : String(error), 236 + stack: error instanceof Error ? error.stack : undefined, 237 + }); 238 + throw error; 239 + } 240 241 + console.error("Failed to look up board record before writing topic to PDS", { 242 + operation: "POST /api/topics", 243 + userId: user.did, 244 + error: error instanceof Error ? error.message : String(error), 245 + }); 246 247 + return c.json( 248 + { error: "Database temporarily unavailable. Please try again later." }, 249 + 503 250 + ); 251 + } 252 + 253 + // Generate TID for rkey 254 + const rkey = TID.nextStr(); 255 + 256 + // Phase 2: PDS write — forum and board are non-null (null case returns 404 above) 257 + try { 258 const result = await user.agent.com.atproto.repo.putRecord({ 259 repo: user.did, 260 collection: "space.atbb.post", 261 rkey, 262 record: { 263 $type: "space.atbb.post", 264 + title: titleValidation.trimmed!, 265 text: validation.trimmed!, 266 forum: { 267 + forum: { uri: forumUri, cid: forum!.cid }, 268 }, 269 board: { 270 + board: { uri: boardUri, cid: board!.cid }, 271 }, 272 createdAt: new Date().toISOString(), 273 }, ··· 299 error: error instanceof Error ? error.message : String(error), 300 }); 301 302 if (error instanceof Error && isNetworkError(error)) { 303 return c.json( 304 { 305 error: "Unable to reach your PDS. Please try again later.", 306 }, 307 503 308 );
+34 -3
apps/web/src/routes/__tests__/new-topic.test.tsx
··· 127 expect(html).toContain('value="42"'); 128 }); 129 130 it("renders form with textarea for message text", async () => { 131 mockFetch.mockResolvedValueOnce(authSession); 132 mockFetch.mockResolvedValueOnce(makeBoardResponse()); ··· 192 describe("POST /new-topic", () => { 193 function makePostBody(overrides: Record<string, string> = {}) { 194 return new URLSearchParams({ 195 text: "Hello forum!", 196 boardUri: "at://did:plc:forum/space.atbb.forum.board/boardrkey1", 197 boardId: "42", ··· 220 expect(res.headers.get("HX-Redirect")).toBe("/boards/42?posted=1"); 221 }); 222 223 it("returns error fragment when text field is missing", async () => { 224 const routes = await loadNewTopicRoutes(); 225 const res = await routes.request("/new-topic", { ··· 240 const res = await routes.request("/new-topic", { 241 method: "POST", 242 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 243 - body: new URLSearchParams({ text: "Hello", boardId: "42" }).toString(), 244 }); 245 expect(res.status).toBe(200); 246 const html = await res.text(); ··· 254 method: "POST", 255 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 256 body: new URLSearchParams({ 257 text: "Hello", 258 boardUri: "at://did:plc:forum/space.atbb.forum.board/boardrkey1", 259 boardId: "abc", ··· 389 expect(fetchOptions.headers["Cookie"]).toContain("atbb_session=token"); 390 }); 391 392 - it("sends text and boardUri as JSON to AppView", async () => { 393 mockFetch.mockResolvedValueOnce({ 394 ok: true, 395 status: 201, ··· 399 await routes.request("/new-topic", { 400 method: "POST", 401 headers: postHeaders, 402 - body: makePostBody({ text: "My post text" }), 403 }); 404 const [fetchUrl, fetchOptions] = mockFetch.mock.calls[0]; 405 const sentBody = JSON.parse(fetchOptions.body); 406 expect(sentBody.text).toBe("My post text"); 407 expect(sentBody.boardUri).toBe("at://did:plc:forum/space.atbb.forum.board/boardrkey1"); 408 expect(fetchUrl).toContain("/api/topics");
··· 127 expect(html).toContain('value="42"'); 128 }); 129 130 + it("renders form with title input field", async () => { 131 + mockFetch.mockResolvedValueOnce(authSession); 132 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); 133 + const routes = await loadNewTopicRoutes(); 134 + const res = await routes.request("/new-topic?boardId=42", { 135 + headers: { cookie: "atbb_session=token" }, 136 + }); 137 + const html = await res.text(); 138 + expect(html).toContain('name="title"'); 139 + expect(html).toContain("title-char-count"); 140 + expect(html).toContain("120"); 141 + }); 142 + 143 it("renders form with textarea for message text", async () => { 144 mockFetch.mockResolvedValueOnce(authSession); 145 mockFetch.mockResolvedValueOnce(makeBoardResponse()); ··· 205 describe("POST /new-topic", () => { 206 function makePostBody(overrides: Record<string, string> = {}) { 207 return new URLSearchParams({ 208 + title: "My Topic Title", 209 text: "Hello forum!", 210 boardUri: "at://did:plc:forum/space.atbb.forum.board/boardrkey1", 211 boardId: "42", ··· 234 expect(res.headers.get("HX-Redirect")).toBe("/boards/42?posted=1"); 235 }); 236 237 + it("returns error fragment when title field is missing", async () => { 238 + const routes = await loadNewTopicRoutes(); 239 + const res = await routes.request("/new-topic", { 240 + method: "POST", 241 + headers: postHeaders, 242 + body: makePostBody({ title: "" }), 243 + }); 244 + expect(res.status).toBe(200); 245 + const html = await res.text(); 246 + expect(html).toContain("form-error"); 247 + expect(html).toContain("title"); 248 + // No fetch call made (validated before proxying) 249 + expect(mockFetch).not.toHaveBeenCalled(); 250 + }); 251 + 252 it("returns error fragment when text field is missing", async () => { 253 const routes = await loadNewTopicRoutes(); 254 const res = await routes.request("/new-topic", { ··· 269 const res = await routes.request("/new-topic", { 270 method: "POST", 271 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 272 + body: new URLSearchParams({ title: "Test", text: "Hello", boardId: "42" }).toString(), 273 }); 274 expect(res.status).toBe(200); 275 const html = await res.text(); ··· 283 method: "POST", 284 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 285 body: new URLSearchParams({ 286 + title: "Test", 287 text: "Hello", 288 boardUri: "at://did:plc:forum/space.atbb.forum.board/boardrkey1", 289 boardId: "abc", ··· 419 expect(fetchOptions.headers["Cookie"]).toContain("atbb_session=token"); 420 }); 421 422 + it("sends title, text and boardUri as JSON to AppView", async () => { 423 mockFetch.mockResolvedValueOnce({ 424 ok: true, 425 status: 201, ··· 429 await routes.request("/new-topic", { 430 method: "POST", 431 headers: postHeaders, 432 + body: makePostBody({ title: "Custom Title", text: "My post text" }), 433 }); 434 const [fetchUrl, fetchOptions] = mockFetch.mock.calls[0]; 435 const sentBody = JSON.parse(fetchOptions.body); 436 + expect(sentBody.title).toBe("Custom Title"); 437 expect(sentBody.text).toBe("My post text"); 438 expect(sentBody.boardUri).toBe("at://did:plc:forum/space.atbb.forum.board/boardrkey1"); 439 expect(fetchUrl).toContain("/api/topics");
+2 -1
apps/web/src/routes/boards.tsx
··· 43 id: string; 44 did: string; 45 rkey: string; 46 text: string; 47 forumUri: string | null; 48 boardUri: string | null; ··· 62 // ─── Inline components ────────────────────────────────────────────────────── 63 64 function TopicRow({ topic }: { topic: TopicResponse }) { 65 - const title = topic.text.slice(0, 80); 66 const handle = topic.author?.handle ?? topic.author?.did ?? topic.did; 67 const date = topic.createdAt ? timeAgo(new Date(topic.createdAt)) : "unknown"; 68 return (
··· 43 id: string; 44 did: string; 45 rkey: string; 46 + title: string | null; 47 text: string; 48 forumUri: string | null; 49 boardUri: string | null; ··· 63 // ─── Inline components ────────────────────────────────────────────────────── 64 65 function TopicRow({ topic }: { topic: TopicResponse }) { 66 + const title = topic.title ?? topic.text.slice(0, 80); 67 const handle = topic.author?.handle ?? topic.author?.did ?? topic.did; 68 const date = topic.createdAt ? timeAgo(new Date(topic.createdAt)) : "unknown"; 69 return (
+26 -2
apps/web/src/routes/new-topic.tsx
··· 30 counter.textContent = (300 - n) + " left"; 31 counter.dataset.over = n > 300 ? "true" : "false"; 32 } 33 `; 34 35 export function createNewTopicRoutes(appviewUrl: string) { ··· 102 <input type="hidden" name="boardId" value={board.id} /> 103 104 <div class="form-group"> 105 <label for="compose-text">Your message</label> 106 <textarea 107 id="compose-text" ··· 144 return c.html(<p class="form-error">Invalid form submission.</p>); 145 } 146 147 - const { text, boardUri, boardId } = body; 148 149 // Validate required fields before proxying 150 if (typeof text !== "string" || !text.trim()) { 151 return c.html(<p class="form-error">Message text is required.</p>); 152 } ··· 170 "Content-Type": "application/json", 171 "Cookie": cookieHeader, 172 }, 173 - body: JSON.stringify({ text, boardUri }), 174 }); 175 } catch (error) { 176 if (isProgrammingError(error)) throw error;
··· 30 counter.textContent = (300 - n) + " left"; 31 counter.dataset.over = n > 300 ? "true" : "false"; 32 } 33 + function updateTitleCharCount(el) { 34 + var seg = new Intl.Segmenter(); 35 + var n = Array.from(seg.segment(el.value)).length; 36 + var counter = document.getElementById("title-char-count"); 37 + counter.textContent = (120 - n) + " left"; 38 + counter.dataset.over = n > 120 ? "true" : "false"; 39 + } 40 `; 41 42 export function createNewTopicRoutes(appviewUrl: string) { ··· 109 <input type="hidden" name="boardId" value={board.id} /> 110 111 <div class="form-group"> 112 + <label for="compose-title">Topic title</label> 113 + <input 114 + id="compose-title" 115 + name="title" 116 + type="text" 117 + placeholder="Give your topic a title" 118 + oninput="updateTitleCharCount(this)" 119 + aria-required="true" 120 + aria-describedby="title-char-count" 121 + /> 122 + <div id="title-char-count" class="char-count" aria-live="polite">120 left</div> 123 + </div> 124 + 125 + <div class="form-group"> 126 <label for="compose-text">Your message</label> 127 <textarea 128 id="compose-text" ··· 165 return c.html(<p class="form-error">Invalid form submission.</p>); 166 } 167 168 + const { title, text, boardUri, boardId } = body; 169 170 // Validate required fields before proxying 171 + if (typeof title !== "string" || !title.trim()) { 172 + return c.html(<p class="form-error">Topic title is required.</p>); 173 + } 174 if (typeof text !== "string" || !text.trim()) { 175 return c.html(<p class="form-error">Message text is required.</p>); 176 } ··· 194 "Content-Type": "application/json", 195 "Cookie": cookieHeader, 196 }, 197 + body: JSON.stringify({ title, text, boardUri }), 198 }); 199 } catch (error) { 200 if (isProgrammingError(error)) throw error;
+2 -1
apps/web/src/routes/topics.tsx
··· 26 id: string; 27 did: string; 28 rkey: string; 29 text: string; 30 forumUri: string | null; 31 boardUri: string | null; ··· 385 const initialReplies = allReplies.slice(0, displayCount); 386 const total = allReplies.length; 387 388 - const topicTitle = topicData.post.text.slice(0, 60); 389 390 return c.html( 391 <BaseLayout title={`${topicTitle} — atBB Forum`} auth={auth}>
··· 26 id: string; 27 did: string; 28 rkey: string; 29 + title: string | null; 30 text: string; 31 forumUri: string | null; 32 boardUri: string | null; ··· 386 const initialReplies = allReplies.slice(0, displayCount); 387 const total = allReplies.length; 388 389 + const topicTitle = topicData.post.title ?? topicData.post.text.slice(0, 60); 390 391 return c.html( 392 <BaseLayout title={`${topicTitle} — atBB Forum`} auth={auth}>
+1
bruno/AppView API/Boards/Get Board Topics.bru
··· 35 "id": "123", 36 "did": "did:plc:...", 37 "rkey": "3lbk7...", 38 "text": "Topic text", 39 "forumUri": "at://did:plc:.../space.atbb.forum.forum/self", 40 "boardUri": "at://did:plc:.../space.atbb.forum.board/...",
··· 35 "id": "123", 36 "did": "did:plc:...", 37 "rkey": "3lbk7...", 38 + "title": "Topic title", 39 "text": "Topic text", 40 "forumUri": "at://did:plc:.../space.atbb.forum.forum/self", 41 "boardUri": "at://did:plc:.../space.atbb.forum.board/...",
+4 -1
bruno/AppView API/Topics/Create Topic.bru
··· 14 15 body:json { 16 { 17 "text": "My new topic", 18 "boardUri": "at://{{forum_did}}/space.atbb.forum.board/{{board_rkey}}" 19 } ··· 31 Requires authentication via session cookie. 32 33 Body parameters: 34 - text: string (required) - Topic text content (1-3000 chars, max 300 graphemes) 35 - boardUri: string (required) - AT URI of the board (at://did/space.atbb.forum.board/rkey) 36 ··· 42 } 43 44 The post record written to PDS includes: 45 - forum reference (singleton, auto-configured) 46 - board reference (from boardUri parameter) 47 48 Error codes: 49 - - 400: Invalid input (missing text, invalid boardUri, malformed JSON) 50 - 401: Unauthorized (not authenticated) 51 - 403: Forbidden (user is banned from this forum) 52 - 404: Board not found
··· 14 15 body:json { 16 { 17 + "title": "My new topic title", 18 "text": "My new topic", 19 "boardUri": "at://{{forum_did}}/space.atbb.forum.board/{{board_rkey}}" 20 } ··· 32 Requires authentication via session cookie. 33 34 Body parameters: 35 + - title: string (required) - Topic title (max 120 graphemes; required) 36 - text: string (required) - Topic text content (1-3000 chars, max 300 graphemes) 37 - boardUri: string (required) - AT URI of the board (at://did/space.atbb.forum.board/rkey) 38 ··· 44 } 45 46 The post record written to PDS includes: 47 + - title (topic title) 48 - forum reference (singleton, auto-configured) 49 - board reference (from boardUri parameter) 50 51 Error codes: 52 + - 400: Invalid input (missing or empty title, missing text, invalid boardUri, malformed JSON) 53 - 401: Unauthorized (not authenticated) 54 - 403: Forbidden (user is banned from this forum) 55 - 404: Board not found
+2
bruno/AppView API/Topics/Get Topic.bru
··· 36 "id": "1", 37 "did": "did:plc:...", 38 "rkey": "...", 39 "text": "Topic text", 40 "forumUri": "at://did:plc:.../space.atbb.forum.forum/self", 41 "createdAt": "2024-01-01T00:00:00.000Z", ··· 49 "id": "2", 50 "did": "did:plc:...", 51 "rkey": "...", 52 "text": "Reply text", 53 "parentPostId": "1", 54 "createdAt": "2024-01-01T00:00:00.000Z",
··· 36 "id": "1", 37 "did": "did:plc:...", 38 "rkey": "...", 39 + "title": "Topic title", 40 "text": "Topic text", 41 "forumUri": "at://did:plc:.../space.atbb.forum.forum/self", 42 "createdAt": "2024-01-01T00:00:00.000Z", ··· 50 "id": "2", 51 "did": "did:plc:...", 52 "rkey": "...", 53 + "title": null, 54 "text": "Reply text", 55 "parentPostId": "1", 56 "createdAt": "2024-01-01T00:00:00.000Z",
+1
packages/db/src/schema.ts
··· 126 .references(() => users.did), 127 rkey: text("rkey").notNull(), 128 cid: text("cid").notNull(), 129 text: text("text").notNull(), 130 forumUri: text("forum_uri"), 131 boardUri: text("board_uri"),
··· 126 .references(() => users.did), 127 rkey: text("rkey").notNull(), 128 cid: text("cid").notNull(), 129 + title: text("title"), 130 text: text("text").notNull(), 131 forumUri: text("forum_uri"), 132 boardUri: text("board_uri"),
+8
packages/lexicon/lexicons/space/atbb/post.yaml
··· 14 - text 15 - createdAt 16 properties: 17 text: 18 type: string 19 maxLength: 3000
··· 14 - text 15 - createdAt 16 properties: 17 + title: 18 + type: string 19 + maxLength: 1000 20 + maxGraphemes: 120 21 + minGraphemes: 1 22 + description: >- 23 + Topic title. Required for thread starters (topics), 24 + omitted for replies. 25 text: 26 type: string 27 maxLength: 3000