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.

Merge origin/main into claude/refactoring-opportunities-UHFCz-local

Resolve import conflict in admin.ts:
- Keep themes, themePolicies imports added by main (ATB-57 theme API)
- Keep inArray from PR (replaces pg-core alias for SQLite compat)
- Keep or from main (used by theme routes)
- Drop alias import from drizzle-orm/pg-core (removed by this PR)

Also mark refactoring-opportunities plan item #1 as complete.

Malpercio 454c25aa b06e148c

+11145 -59
+8
.env.production.example
··· 55 55 # - Bluesky PDS: https://bsky.social 56 56 PDS_URL=https://pds.example.com 57 57 58 + # Forum Service Account credentials 59 + # Required for the AppView to write forum-level records (categories, roles, mod actions) 60 + # to the PDS on behalf of the forum identity. 61 + # 62 + # Use the handle and password for the AT Protocol account created for your forum. 63 + FORUM_HANDLE=forum.example.com 64 + FORUM_PASSWORD=CHANGE_ME_STRONG_PASSWORD 65 + 58 66 # ============================================================================ 59 67 # Application URLs 60 68 # ============================================================================
+1 -1
CLAUDE.md
··· 117 117 - **References:** Use `com.atproto.repo.strongRef` wrapped in named defs (e.g., `forumRef`, `subjectRef`). 118 118 - **Extensible fields:** Use `knownValues` (not `enum`) for strings that may grow (permissions, reaction types, mod actions). 119 119 - **Record ownership:** 120 - - Forum DID owns: `forum.forum`, `forum.category`, `forum.board`, `forum.role`, `modAction` 120 + - Forum DID owns: `forum.forum`, `forum.category`, `forum.board`, `forum.role`, `forum.theme`, `forum.themePolicy`, `modAction` 121 121 - User DID owns: `post`, `membership`, `reaction` 122 122 123 123 ## AT Protocol Conventions
+3
README.md
··· 41 41 42 42 | Package | Description | 43 43 |---------|-------------| 44 + | [`packages/atproto`](packages/atproto) | AT Protocol utilities: ForumAgent, error classification, identity resolution | 45 + | [`packages/cli`](packages/cli) | Forum management CLI (`atbb`) for initializing forums, categories, and boards | 44 46 | [`packages/db`](packages/db) | Drizzle ORM schema and connection factory (PostgreSQL and SQLite/LibSQL) | 45 47 | [`packages/lexicon`](packages/lexicon) | AT Proto lexicon schemas (YAML) and generated TypeScript types | 48 + | [`packages/logger`](packages/logger) | Structured logging with OpenTelemetry and Hono middleware support | 46 49 47 50 ## Getting Started 48 51
+35
apps/appview/drizzle-sqlite/0001_add_theme_tables.sql
··· 1 + CREATE TABLE `theme_policies` ( 2 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 + `did` text NOT NULL, 4 + `rkey` text NOT NULL, 5 + `cid` text NOT NULL, 6 + `default_light_theme_uri` text NOT NULL, 7 + `default_dark_theme_uri` text NOT NULL, 8 + `allow_user_choice` integer NOT NULL, 9 + `indexed_at` integer NOT NULL 10 + ); 11 + --> statement-breakpoint 12 + CREATE UNIQUE INDEX `theme_policies_did_rkey_idx` ON `theme_policies` (`did`,`rkey`);--> statement-breakpoint 13 + CREATE TABLE `theme_policy_available_themes` ( 14 + `policy_id` integer NOT NULL, 15 + `theme_uri` text NOT NULL, 16 + `theme_cid` text NOT NULL, 17 + PRIMARY KEY(`policy_id`, `theme_uri`), 18 + FOREIGN KEY (`policy_id`) REFERENCES `theme_policies`(`id`) ON UPDATE no action ON DELETE cascade 19 + ); 20 + --> statement-breakpoint 21 + CREATE TABLE `themes` ( 22 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 23 + `did` text NOT NULL, 24 + `rkey` text NOT NULL, 25 + `cid` text NOT NULL, 26 + `name` text NOT NULL, 27 + `color_scheme` text NOT NULL, 28 + `tokens` text NOT NULL, 29 + `css_overrides` text, 30 + `font_urls` text, 31 + `created_at` integer NOT NULL, 32 + `indexed_at` integer NOT NULL 33 + ); 34 + --> statement-breakpoint 35 + CREATE UNIQUE INDEX `themes_did_rkey_idx` ON `themes` (`did`,`rkey`);
+1396
apps/appview/drizzle-sqlite/meta/0001_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "8aa48416-5eb6-492c-8206-e1f32f752f5a", 5 + "prevId": "84f6d742-677b-4159-86e3-9c5abadccec5", 6 + "tables": { 7 + "backfill_errors": { 8 + "name": "backfill_errors", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "integer", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": true 16 + }, 17 + "backfill_id": { 18 + "name": "backfill_id", 19 + "type": "integer", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "did": { 25 + "name": "did", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "collection": { 32 + "name": "collection", 33 + "type": "text", 34 + "primaryKey": false, 35 + "notNull": true, 36 + "autoincrement": false 37 + }, 38 + "error_message": { 39 + "name": "error_message", 40 + "type": "text", 41 + "primaryKey": false, 42 + "notNull": true, 43 + "autoincrement": false 44 + }, 45 + "created_at": { 46 + "name": "created_at", 47 + "type": "integer", 48 + "primaryKey": false, 49 + "notNull": true, 50 + "autoincrement": false 51 + } 52 + }, 53 + "indexes": { 54 + "backfill_errors_backfill_id_idx": { 55 + "name": "backfill_errors_backfill_id_idx", 56 + "columns": [ 57 + "backfill_id" 58 + ], 59 + "isUnique": false 60 + } 61 + }, 62 + "foreignKeys": { 63 + "backfill_errors_backfill_id_backfill_progress_id_fk": { 64 + "name": "backfill_errors_backfill_id_backfill_progress_id_fk", 65 + "tableFrom": "backfill_errors", 66 + "tableTo": "backfill_progress", 67 + "columnsFrom": [ 68 + "backfill_id" 69 + ], 70 + "columnsTo": [ 71 + "id" 72 + ], 73 + "onDelete": "no action", 74 + "onUpdate": "no action" 75 + } 76 + }, 77 + "compositePrimaryKeys": {}, 78 + "uniqueConstraints": {}, 79 + "checkConstraints": {} 80 + }, 81 + "backfill_progress": { 82 + "name": "backfill_progress", 83 + "columns": { 84 + "id": { 85 + "name": "id", 86 + "type": "integer", 87 + "primaryKey": true, 88 + "notNull": true, 89 + "autoincrement": true 90 + }, 91 + "status": { 92 + "name": "status", 93 + "type": "text", 94 + "primaryKey": false, 95 + "notNull": true, 96 + "autoincrement": false 97 + }, 98 + "backfill_type": { 99 + "name": "backfill_type", 100 + "type": "text", 101 + "primaryKey": false, 102 + "notNull": true, 103 + "autoincrement": false 104 + }, 105 + "last_processed_did": { 106 + "name": "last_processed_did", 107 + "type": "text", 108 + "primaryKey": false, 109 + "notNull": false, 110 + "autoincrement": false 111 + }, 112 + "dids_total": { 113 + "name": "dids_total", 114 + "type": "integer", 115 + "primaryKey": false, 116 + "notNull": true, 117 + "autoincrement": false, 118 + "default": 0 119 + }, 120 + "dids_processed": { 121 + "name": "dids_processed", 122 + "type": "integer", 123 + "primaryKey": false, 124 + "notNull": true, 125 + "autoincrement": false, 126 + "default": 0 127 + }, 128 + "records_indexed": { 129 + "name": "records_indexed", 130 + "type": "integer", 131 + "primaryKey": false, 132 + "notNull": true, 133 + "autoincrement": false, 134 + "default": 0 135 + }, 136 + "started_at": { 137 + "name": "started_at", 138 + "type": "integer", 139 + "primaryKey": false, 140 + "notNull": true, 141 + "autoincrement": false 142 + }, 143 + "completed_at": { 144 + "name": "completed_at", 145 + "type": "integer", 146 + "primaryKey": false, 147 + "notNull": false, 148 + "autoincrement": false 149 + }, 150 + "error_message": { 151 + "name": "error_message", 152 + "type": "text", 153 + "primaryKey": false, 154 + "notNull": false, 155 + "autoincrement": false 156 + } 157 + }, 158 + "indexes": {}, 159 + "foreignKeys": {}, 160 + "compositePrimaryKeys": {}, 161 + "uniqueConstraints": {}, 162 + "checkConstraints": {} 163 + }, 164 + "boards": { 165 + "name": "boards", 166 + "columns": { 167 + "id": { 168 + "name": "id", 169 + "type": "integer", 170 + "primaryKey": true, 171 + "notNull": true, 172 + "autoincrement": true 173 + }, 174 + "did": { 175 + "name": "did", 176 + "type": "text", 177 + "primaryKey": false, 178 + "notNull": true, 179 + "autoincrement": false 180 + }, 181 + "rkey": { 182 + "name": "rkey", 183 + "type": "text", 184 + "primaryKey": false, 185 + "notNull": true, 186 + "autoincrement": false 187 + }, 188 + "cid": { 189 + "name": "cid", 190 + "type": "text", 191 + "primaryKey": false, 192 + "notNull": true, 193 + "autoincrement": false 194 + }, 195 + "name": { 196 + "name": "name", 197 + "type": "text", 198 + "primaryKey": false, 199 + "notNull": true, 200 + "autoincrement": false 201 + }, 202 + "description": { 203 + "name": "description", 204 + "type": "text", 205 + "primaryKey": false, 206 + "notNull": false, 207 + "autoincrement": false 208 + }, 209 + "slug": { 210 + "name": "slug", 211 + "type": "text", 212 + "primaryKey": false, 213 + "notNull": false, 214 + "autoincrement": false 215 + }, 216 + "sort_order": { 217 + "name": "sort_order", 218 + "type": "integer", 219 + "primaryKey": false, 220 + "notNull": false, 221 + "autoincrement": false 222 + }, 223 + "category_id": { 224 + "name": "category_id", 225 + "type": "integer", 226 + "primaryKey": false, 227 + "notNull": false, 228 + "autoincrement": false 229 + }, 230 + "category_uri": { 231 + "name": "category_uri", 232 + "type": "text", 233 + "primaryKey": false, 234 + "notNull": true, 235 + "autoincrement": false 236 + }, 237 + "created_at": { 238 + "name": "created_at", 239 + "type": "integer", 240 + "primaryKey": false, 241 + "notNull": true, 242 + "autoincrement": false 243 + }, 244 + "indexed_at": { 245 + "name": "indexed_at", 246 + "type": "integer", 247 + "primaryKey": false, 248 + "notNull": true, 249 + "autoincrement": false 250 + } 251 + }, 252 + "indexes": { 253 + "boards_did_rkey_idx": { 254 + "name": "boards_did_rkey_idx", 255 + "columns": [ 256 + "did", 257 + "rkey" 258 + ], 259 + "isUnique": true 260 + }, 261 + "boards_category_id_idx": { 262 + "name": "boards_category_id_idx", 263 + "columns": [ 264 + "category_id" 265 + ], 266 + "isUnique": false 267 + } 268 + }, 269 + "foreignKeys": { 270 + "boards_category_id_categories_id_fk": { 271 + "name": "boards_category_id_categories_id_fk", 272 + "tableFrom": "boards", 273 + "tableTo": "categories", 274 + "columnsFrom": [ 275 + "category_id" 276 + ], 277 + "columnsTo": [ 278 + "id" 279 + ], 280 + "onDelete": "no action", 281 + "onUpdate": "no action" 282 + } 283 + }, 284 + "compositePrimaryKeys": {}, 285 + "uniqueConstraints": {}, 286 + "checkConstraints": {} 287 + }, 288 + "categories": { 289 + "name": "categories", 290 + "columns": { 291 + "id": { 292 + "name": "id", 293 + "type": "integer", 294 + "primaryKey": true, 295 + "notNull": true, 296 + "autoincrement": true 297 + }, 298 + "did": { 299 + "name": "did", 300 + "type": "text", 301 + "primaryKey": false, 302 + "notNull": true, 303 + "autoincrement": false 304 + }, 305 + "rkey": { 306 + "name": "rkey", 307 + "type": "text", 308 + "primaryKey": false, 309 + "notNull": true, 310 + "autoincrement": false 311 + }, 312 + "cid": { 313 + "name": "cid", 314 + "type": "text", 315 + "primaryKey": false, 316 + "notNull": true, 317 + "autoincrement": false 318 + }, 319 + "name": { 320 + "name": "name", 321 + "type": "text", 322 + "primaryKey": false, 323 + "notNull": true, 324 + "autoincrement": false 325 + }, 326 + "description": { 327 + "name": "description", 328 + "type": "text", 329 + "primaryKey": false, 330 + "notNull": false, 331 + "autoincrement": false 332 + }, 333 + "slug": { 334 + "name": "slug", 335 + "type": "text", 336 + "primaryKey": false, 337 + "notNull": false, 338 + "autoincrement": false 339 + }, 340 + "sort_order": { 341 + "name": "sort_order", 342 + "type": "integer", 343 + "primaryKey": false, 344 + "notNull": false, 345 + "autoincrement": false 346 + }, 347 + "forum_id": { 348 + "name": "forum_id", 349 + "type": "integer", 350 + "primaryKey": false, 351 + "notNull": false, 352 + "autoincrement": false 353 + }, 354 + "created_at": { 355 + "name": "created_at", 356 + "type": "integer", 357 + "primaryKey": false, 358 + "notNull": true, 359 + "autoincrement": false 360 + }, 361 + "indexed_at": { 362 + "name": "indexed_at", 363 + "type": "integer", 364 + "primaryKey": false, 365 + "notNull": true, 366 + "autoincrement": false 367 + } 368 + }, 369 + "indexes": { 370 + "categories_did_rkey_idx": { 371 + "name": "categories_did_rkey_idx", 372 + "columns": [ 373 + "did", 374 + "rkey" 375 + ], 376 + "isUnique": true 377 + } 378 + }, 379 + "foreignKeys": { 380 + "categories_forum_id_forums_id_fk": { 381 + "name": "categories_forum_id_forums_id_fk", 382 + "tableFrom": "categories", 383 + "tableTo": "forums", 384 + "columnsFrom": [ 385 + "forum_id" 386 + ], 387 + "columnsTo": [ 388 + "id" 389 + ], 390 + "onDelete": "no action", 391 + "onUpdate": "no action" 392 + } 393 + }, 394 + "compositePrimaryKeys": {}, 395 + "uniqueConstraints": {}, 396 + "checkConstraints": {} 397 + }, 398 + "firehose_cursor": { 399 + "name": "firehose_cursor", 400 + "columns": { 401 + "service": { 402 + "name": "service", 403 + "type": "text", 404 + "primaryKey": true, 405 + "notNull": true, 406 + "autoincrement": false, 407 + "default": "'jetstream'" 408 + }, 409 + "cursor": { 410 + "name": "cursor", 411 + "type": "integer", 412 + "primaryKey": false, 413 + "notNull": true, 414 + "autoincrement": false 415 + }, 416 + "updated_at": { 417 + "name": "updated_at", 418 + "type": "integer", 419 + "primaryKey": false, 420 + "notNull": true, 421 + "autoincrement": false 422 + } 423 + }, 424 + "indexes": {}, 425 + "foreignKeys": {}, 426 + "compositePrimaryKeys": {}, 427 + "uniqueConstraints": {}, 428 + "checkConstraints": {} 429 + }, 430 + "forums": { 431 + "name": "forums", 432 + "columns": { 433 + "id": { 434 + "name": "id", 435 + "type": "integer", 436 + "primaryKey": true, 437 + "notNull": true, 438 + "autoincrement": true 439 + }, 440 + "did": { 441 + "name": "did", 442 + "type": "text", 443 + "primaryKey": false, 444 + "notNull": true, 445 + "autoincrement": false 446 + }, 447 + "rkey": { 448 + "name": "rkey", 449 + "type": "text", 450 + "primaryKey": false, 451 + "notNull": true, 452 + "autoincrement": false 453 + }, 454 + "cid": { 455 + "name": "cid", 456 + "type": "text", 457 + "primaryKey": false, 458 + "notNull": true, 459 + "autoincrement": false 460 + }, 461 + "name": { 462 + "name": "name", 463 + "type": "text", 464 + "primaryKey": false, 465 + "notNull": true, 466 + "autoincrement": false 467 + }, 468 + "description": { 469 + "name": "description", 470 + "type": "text", 471 + "primaryKey": false, 472 + "notNull": false, 473 + "autoincrement": false 474 + }, 475 + "indexed_at": { 476 + "name": "indexed_at", 477 + "type": "integer", 478 + "primaryKey": false, 479 + "notNull": true, 480 + "autoincrement": false 481 + } 482 + }, 483 + "indexes": { 484 + "forums_did_rkey_idx": { 485 + "name": "forums_did_rkey_idx", 486 + "columns": [ 487 + "did", 488 + "rkey" 489 + ], 490 + "isUnique": true 491 + } 492 + }, 493 + "foreignKeys": {}, 494 + "compositePrimaryKeys": {}, 495 + "uniqueConstraints": {}, 496 + "checkConstraints": {} 497 + }, 498 + "memberships": { 499 + "name": "memberships", 500 + "columns": { 501 + "id": { 502 + "name": "id", 503 + "type": "integer", 504 + "primaryKey": true, 505 + "notNull": true, 506 + "autoincrement": true 507 + }, 508 + "did": { 509 + "name": "did", 510 + "type": "text", 511 + "primaryKey": false, 512 + "notNull": true, 513 + "autoincrement": false 514 + }, 515 + "rkey": { 516 + "name": "rkey", 517 + "type": "text", 518 + "primaryKey": false, 519 + "notNull": true, 520 + "autoincrement": false 521 + }, 522 + "cid": { 523 + "name": "cid", 524 + "type": "text", 525 + "primaryKey": false, 526 + "notNull": true, 527 + "autoincrement": false 528 + }, 529 + "forum_id": { 530 + "name": "forum_id", 531 + "type": "integer", 532 + "primaryKey": false, 533 + "notNull": false, 534 + "autoincrement": false 535 + }, 536 + "forum_uri": { 537 + "name": "forum_uri", 538 + "type": "text", 539 + "primaryKey": false, 540 + "notNull": true, 541 + "autoincrement": false 542 + }, 543 + "role": { 544 + "name": "role", 545 + "type": "text", 546 + "primaryKey": false, 547 + "notNull": false, 548 + "autoincrement": false 549 + }, 550 + "role_uri": { 551 + "name": "role_uri", 552 + "type": "text", 553 + "primaryKey": false, 554 + "notNull": false, 555 + "autoincrement": false 556 + }, 557 + "joined_at": { 558 + "name": "joined_at", 559 + "type": "integer", 560 + "primaryKey": false, 561 + "notNull": false, 562 + "autoincrement": false 563 + }, 564 + "created_at": { 565 + "name": "created_at", 566 + "type": "integer", 567 + "primaryKey": false, 568 + "notNull": true, 569 + "autoincrement": false 570 + }, 571 + "indexed_at": { 572 + "name": "indexed_at", 573 + "type": "integer", 574 + "primaryKey": false, 575 + "notNull": true, 576 + "autoincrement": false 577 + } 578 + }, 579 + "indexes": { 580 + "memberships_did_rkey_idx": { 581 + "name": "memberships_did_rkey_idx", 582 + "columns": [ 583 + "did", 584 + "rkey" 585 + ], 586 + "isUnique": true 587 + }, 588 + "memberships_did_idx": { 589 + "name": "memberships_did_idx", 590 + "columns": [ 591 + "did" 592 + ], 593 + "isUnique": false 594 + } 595 + }, 596 + "foreignKeys": { 597 + "memberships_did_users_did_fk": { 598 + "name": "memberships_did_users_did_fk", 599 + "tableFrom": "memberships", 600 + "tableTo": "users", 601 + "columnsFrom": [ 602 + "did" 603 + ], 604 + "columnsTo": [ 605 + "did" 606 + ], 607 + "onDelete": "no action", 608 + "onUpdate": "no action" 609 + }, 610 + "memberships_forum_id_forums_id_fk": { 611 + "name": "memberships_forum_id_forums_id_fk", 612 + "tableFrom": "memberships", 613 + "tableTo": "forums", 614 + "columnsFrom": [ 615 + "forum_id" 616 + ], 617 + "columnsTo": [ 618 + "id" 619 + ], 620 + "onDelete": "no action", 621 + "onUpdate": "no action" 622 + } 623 + }, 624 + "compositePrimaryKeys": {}, 625 + "uniqueConstraints": {}, 626 + "checkConstraints": {} 627 + }, 628 + "mod_actions": { 629 + "name": "mod_actions", 630 + "columns": { 631 + "id": { 632 + "name": "id", 633 + "type": "integer", 634 + "primaryKey": true, 635 + "notNull": true, 636 + "autoincrement": true 637 + }, 638 + "did": { 639 + "name": "did", 640 + "type": "text", 641 + "primaryKey": false, 642 + "notNull": true, 643 + "autoincrement": false 644 + }, 645 + "rkey": { 646 + "name": "rkey", 647 + "type": "text", 648 + "primaryKey": false, 649 + "notNull": true, 650 + "autoincrement": false 651 + }, 652 + "cid": { 653 + "name": "cid", 654 + "type": "text", 655 + "primaryKey": false, 656 + "notNull": true, 657 + "autoincrement": false 658 + }, 659 + "action": { 660 + "name": "action", 661 + "type": "text", 662 + "primaryKey": false, 663 + "notNull": true, 664 + "autoincrement": false 665 + }, 666 + "subject_did": { 667 + "name": "subject_did", 668 + "type": "text", 669 + "primaryKey": false, 670 + "notNull": false, 671 + "autoincrement": false 672 + }, 673 + "subject_post_uri": { 674 + "name": "subject_post_uri", 675 + "type": "text", 676 + "primaryKey": false, 677 + "notNull": false, 678 + "autoincrement": false 679 + }, 680 + "forum_id": { 681 + "name": "forum_id", 682 + "type": "integer", 683 + "primaryKey": false, 684 + "notNull": false, 685 + "autoincrement": false 686 + }, 687 + "reason": { 688 + "name": "reason", 689 + "type": "text", 690 + "primaryKey": false, 691 + "notNull": false, 692 + "autoincrement": false 693 + }, 694 + "created_by": { 695 + "name": "created_by", 696 + "type": "text", 697 + "primaryKey": false, 698 + "notNull": true, 699 + "autoincrement": false 700 + }, 701 + "expires_at": { 702 + "name": "expires_at", 703 + "type": "integer", 704 + "primaryKey": false, 705 + "notNull": false, 706 + "autoincrement": false 707 + }, 708 + "created_at": { 709 + "name": "created_at", 710 + "type": "integer", 711 + "primaryKey": false, 712 + "notNull": true, 713 + "autoincrement": false 714 + }, 715 + "indexed_at": { 716 + "name": "indexed_at", 717 + "type": "integer", 718 + "primaryKey": false, 719 + "notNull": true, 720 + "autoincrement": false 721 + } 722 + }, 723 + "indexes": { 724 + "mod_actions_did_rkey_idx": { 725 + "name": "mod_actions_did_rkey_idx", 726 + "columns": [ 727 + "did", 728 + "rkey" 729 + ], 730 + "isUnique": true 731 + }, 732 + "mod_actions_subject_did_idx": { 733 + "name": "mod_actions_subject_did_idx", 734 + "columns": [ 735 + "subject_did" 736 + ], 737 + "isUnique": false 738 + }, 739 + "mod_actions_subject_post_uri_idx": { 740 + "name": "mod_actions_subject_post_uri_idx", 741 + "columns": [ 742 + "subject_post_uri" 743 + ], 744 + "isUnique": false 745 + } 746 + }, 747 + "foreignKeys": { 748 + "mod_actions_forum_id_forums_id_fk": { 749 + "name": "mod_actions_forum_id_forums_id_fk", 750 + "tableFrom": "mod_actions", 751 + "tableTo": "forums", 752 + "columnsFrom": [ 753 + "forum_id" 754 + ], 755 + "columnsTo": [ 756 + "id" 757 + ], 758 + "onDelete": "no action", 759 + "onUpdate": "no action" 760 + } 761 + }, 762 + "compositePrimaryKeys": {}, 763 + "uniqueConstraints": {}, 764 + "checkConstraints": {} 765 + }, 766 + "posts": { 767 + "name": "posts", 768 + "columns": { 769 + "id": { 770 + "name": "id", 771 + "type": "integer", 772 + "primaryKey": true, 773 + "notNull": true, 774 + "autoincrement": true 775 + }, 776 + "did": { 777 + "name": "did", 778 + "type": "text", 779 + "primaryKey": false, 780 + "notNull": true, 781 + "autoincrement": false 782 + }, 783 + "rkey": { 784 + "name": "rkey", 785 + "type": "text", 786 + "primaryKey": false, 787 + "notNull": true, 788 + "autoincrement": false 789 + }, 790 + "cid": { 791 + "name": "cid", 792 + "type": "text", 793 + "primaryKey": false, 794 + "notNull": true, 795 + "autoincrement": false 796 + }, 797 + "title": { 798 + "name": "title", 799 + "type": "text", 800 + "primaryKey": false, 801 + "notNull": false, 802 + "autoincrement": false 803 + }, 804 + "text": { 805 + "name": "text", 806 + "type": "text", 807 + "primaryKey": false, 808 + "notNull": true, 809 + "autoincrement": false 810 + }, 811 + "forum_uri": { 812 + "name": "forum_uri", 813 + "type": "text", 814 + "primaryKey": false, 815 + "notNull": false, 816 + "autoincrement": false 817 + }, 818 + "board_uri": { 819 + "name": "board_uri", 820 + "type": "text", 821 + "primaryKey": false, 822 + "notNull": false, 823 + "autoincrement": false 824 + }, 825 + "board_id": { 826 + "name": "board_id", 827 + "type": "integer", 828 + "primaryKey": false, 829 + "notNull": false, 830 + "autoincrement": false 831 + }, 832 + "root_post_id": { 833 + "name": "root_post_id", 834 + "type": "integer", 835 + "primaryKey": false, 836 + "notNull": false, 837 + "autoincrement": false 838 + }, 839 + "parent_post_id": { 840 + "name": "parent_post_id", 841 + "type": "integer", 842 + "primaryKey": false, 843 + "notNull": false, 844 + "autoincrement": false 845 + }, 846 + "root_uri": { 847 + "name": "root_uri", 848 + "type": "text", 849 + "primaryKey": false, 850 + "notNull": false, 851 + "autoincrement": false 852 + }, 853 + "parent_uri": { 854 + "name": "parent_uri", 855 + "type": "text", 856 + "primaryKey": false, 857 + "notNull": false, 858 + "autoincrement": false 859 + }, 860 + "created_at": { 861 + "name": "created_at", 862 + "type": "integer", 863 + "primaryKey": false, 864 + "notNull": true, 865 + "autoincrement": false 866 + }, 867 + "indexed_at": { 868 + "name": "indexed_at", 869 + "type": "integer", 870 + "primaryKey": false, 871 + "notNull": true, 872 + "autoincrement": false 873 + }, 874 + "banned_by_mod": { 875 + "name": "banned_by_mod", 876 + "type": "integer", 877 + "primaryKey": false, 878 + "notNull": true, 879 + "autoincrement": false, 880 + "default": false 881 + }, 882 + "deleted_by_user": { 883 + "name": "deleted_by_user", 884 + "type": "integer", 885 + "primaryKey": false, 886 + "notNull": true, 887 + "autoincrement": false, 888 + "default": false 889 + } 890 + }, 891 + "indexes": { 892 + "posts_did_rkey_idx": { 893 + "name": "posts_did_rkey_idx", 894 + "columns": [ 895 + "did", 896 + "rkey" 897 + ], 898 + "isUnique": true 899 + }, 900 + "posts_forum_uri_idx": { 901 + "name": "posts_forum_uri_idx", 902 + "columns": [ 903 + "forum_uri" 904 + ], 905 + "isUnique": false 906 + }, 907 + "posts_board_id_idx": { 908 + "name": "posts_board_id_idx", 909 + "columns": [ 910 + "board_id" 911 + ], 912 + "isUnique": false 913 + }, 914 + "posts_board_uri_idx": { 915 + "name": "posts_board_uri_idx", 916 + "columns": [ 917 + "board_uri" 918 + ], 919 + "isUnique": false 920 + }, 921 + "posts_root_post_id_idx": { 922 + "name": "posts_root_post_id_idx", 923 + "columns": [ 924 + "root_post_id" 925 + ], 926 + "isUnique": false 927 + } 928 + }, 929 + "foreignKeys": { 930 + "posts_did_users_did_fk": { 931 + "name": "posts_did_users_did_fk", 932 + "tableFrom": "posts", 933 + "tableTo": "users", 934 + "columnsFrom": [ 935 + "did" 936 + ], 937 + "columnsTo": [ 938 + "did" 939 + ], 940 + "onDelete": "no action", 941 + "onUpdate": "no action" 942 + }, 943 + "posts_board_id_boards_id_fk": { 944 + "name": "posts_board_id_boards_id_fk", 945 + "tableFrom": "posts", 946 + "tableTo": "boards", 947 + "columnsFrom": [ 948 + "board_id" 949 + ], 950 + "columnsTo": [ 951 + "id" 952 + ], 953 + "onDelete": "no action", 954 + "onUpdate": "no action" 955 + }, 956 + "posts_root_post_id_posts_id_fk": { 957 + "name": "posts_root_post_id_posts_id_fk", 958 + "tableFrom": "posts", 959 + "tableTo": "posts", 960 + "columnsFrom": [ 961 + "root_post_id" 962 + ], 963 + "columnsTo": [ 964 + "id" 965 + ], 966 + "onDelete": "no action", 967 + "onUpdate": "no action" 968 + }, 969 + "posts_parent_post_id_posts_id_fk": { 970 + "name": "posts_parent_post_id_posts_id_fk", 971 + "tableFrom": "posts", 972 + "tableTo": "posts", 973 + "columnsFrom": [ 974 + "parent_post_id" 975 + ], 976 + "columnsTo": [ 977 + "id" 978 + ], 979 + "onDelete": "no action", 980 + "onUpdate": "no action" 981 + } 982 + }, 983 + "compositePrimaryKeys": {}, 984 + "uniqueConstraints": {}, 985 + "checkConstraints": {} 986 + }, 987 + "role_permissions": { 988 + "name": "role_permissions", 989 + "columns": { 990 + "role_id": { 991 + "name": "role_id", 992 + "type": "integer", 993 + "primaryKey": false, 994 + "notNull": true, 995 + "autoincrement": false 996 + }, 997 + "permission": { 998 + "name": "permission", 999 + "type": "text", 1000 + "primaryKey": false, 1001 + "notNull": true, 1002 + "autoincrement": false 1003 + } 1004 + }, 1005 + "indexes": {}, 1006 + "foreignKeys": { 1007 + "role_permissions_role_id_roles_id_fk": { 1008 + "name": "role_permissions_role_id_roles_id_fk", 1009 + "tableFrom": "role_permissions", 1010 + "tableTo": "roles", 1011 + "columnsFrom": [ 1012 + "role_id" 1013 + ], 1014 + "columnsTo": [ 1015 + "id" 1016 + ], 1017 + "onDelete": "cascade", 1018 + "onUpdate": "no action" 1019 + } 1020 + }, 1021 + "compositePrimaryKeys": { 1022 + "role_permissions_role_id_permission_pk": { 1023 + "columns": [ 1024 + "role_id", 1025 + "permission" 1026 + ], 1027 + "name": "role_permissions_role_id_permission_pk" 1028 + } 1029 + }, 1030 + "uniqueConstraints": {}, 1031 + "checkConstraints": {} 1032 + }, 1033 + "roles": { 1034 + "name": "roles", 1035 + "columns": { 1036 + "id": { 1037 + "name": "id", 1038 + "type": "integer", 1039 + "primaryKey": true, 1040 + "notNull": true, 1041 + "autoincrement": true 1042 + }, 1043 + "did": { 1044 + "name": "did", 1045 + "type": "text", 1046 + "primaryKey": false, 1047 + "notNull": true, 1048 + "autoincrement": false 1049 + }, 1050 + "rkey": { 1051 + "name": "rkey", 1052 + "type": "text", 1053 + "primaryKey": false, 1054 + "notNull": true, 1055 + "autoincrement": false 1056 + }, 1057 + "cid": { 1058 + "name": "cid", 1059 + "type": "text", 1060 + "primaryKey": false, 1061 + "notNull": true, 1062 + "autoincrement": false 1063 + }, 1064 + "name": { 1065 + "name": "name", 1066 + "type": "text", 1067 + "primaryKey": false, 1068 + "notNull": true, 1069 + "autoincrement": false 1070 + }, 1071 + "description": { 1072 + "name": "description", 1073 + "type": "text", 1074 + "primaryKey": false, 1075 + "notNull": false, 1076 + "autoincrement": false 1077 + }, 1078 + "priority": { 1079 + "name": "priority", 1080 + "type": "integer", 1081 + "primaryKey": false, 1082 + "notNull": true, 1083 + "autoincrement": false 1084 + }, 1085 + "created_at": { 1086 + "name": "created_at", 1087 + "type": "integer", 1088 + "primaryKey": false, 1089 + "notNull": true, 1090 + "autoincrement": false 1091 + }, 1092 + "indexed_at": { 1093 + "name": "indexed_at", 1094 + "type": "integer", 1095 + "primaryKey": false, 1096 + "notNull": true, 1097 + "autoincrement": false 1098 + } 1099 + }, 1100 + "indexes": { 1101 + "roles_did_rkey_idx": { 1102 + "name": "roles_did_rkey_idx", 1103 + "columns": [ 1104 + "did", 1105 + "rkey" 1106 + ], 1107 + "isUnique": true 1108 + }, 1109 + "roles_did_idx": { 1110 + "name": "roles_did_idx", 1111 + "columns": [ 1112 + "did" 1113 + ], 1114 + "isUnique": false 1115 + }, 1116 + "roles_did_name_idx": { 1117 + "name": "roles_did_name_idx", 1118 + "columns": [ 1119 + "did", 1120 + "name" 1121 + ], 1122 + "isUnique": false 1123 + } 1124 + }, 1125 + "foreignKeys": {}, 1126 + "compositePrimaryKeys": {}, 1127 + "uniqueConstraints": {}, 1128 + "checkConstraints": {} 1129 + }, 1130 + "theme_policies": { 1131 + "name": "theme_policies", 1132 + "columns": { 1133 + "id": { 1134 + "name": "id", 1135 + "type": "integer", 1136 + "primaryKey": true, 1137 + "notNull": true, 1138 + "autoincrement": true 1139 + }, 1140 + "did": { 1141 + "name": "did", 1142 + "type": "text", 1143 + "primaryKey": false, 1144 + "notNull": true, 1145 + "autoincrement": false 1146 + }, 1147 + "rkey": { 1148 + "name": "rkey", 1149 + "type": "text", 1150 + "primaryKey": false, 1151 + "notNull": true, 1152 + "autoincrement": false 1153 + }, 1154 + "cid": { 1155 + "name": "cid", 1156 + "type": "text", 1157 + "primaryKey": false, 1158 + "notNull": true, 1159 + "autoincrement": false 1160 + }, 1161 + "default_light_theme_uri": { 1162 + "name": "default_light_theme_uri", 1163 + "type": "text", 1164 + "primaryKey": false, 1165 + "notNull": true, 1166 + "autoincrement": false 1167 + }, 1168 + "default_dark_theme_uri": { 1169 + "name": "default_dark_theme_uri", 1170 + "type": "text", 1171 + "primaryKey": false, 1172 + "notNull": true, 1173 + "autoincrement": false 1174 + }, 1175 + "allow_user_choice": { 1176 + "name": "allow_user_choice", 1177 + "type": "integer", 1178 + "primaryKey": false, 1179 + "notNull": true, 1180 + "autoincrement": false 1181 + }, 1182 + "indexed_at": { 1183 + "name": "indexed_at", 1184 + "type": "integer", 1185 + "primaryKey": false, 1186 + "notNull": true, 1187 + "autoincrement": false 1188 + } 1189 + }, 1190 + "indexes": { 1191 + "theme_policies_did_rkey_idx": { 1192 + "name": "theme_policies_did_rkey_idx", 1193 + "columns": [ 1194 + "did", 1195 + "rkey" 1196 + ], 1197 + "isUnique": true 1198 + } 1199 + }, 1200 + "foreignKeys": {}, 1201 + "compositePrimaryKeys": {}, 1202 + "uniqueConstraints": {}, 1203 + "checkConstraints": {} 1204 + }, 1205 + "theme_policy_available_themes": { 1206 + "name": "theme_policy_available_themes", 1207 + "columns": { 1208 + "policy_id": { 1209 + "name": "policy_id", 1210 + "type": "integer", 1211 + "primaryKey": false, 1212 + "notNull": true, 1213 + "autoincrement": false 1214 + }, 1215 + "theme_uri": { 1216 + "name": "theme_uri", 1217 + "type": "text", 1218 + "primaryKey": false, 1219 + "notNull": true, 1220 + "autoincrement": false 1221 + }, 1222 + "theme_cid": { 1223 + "name": "theme_cid", 1224 + "type": "text", 1225 + "primaryKey": false, 1226 + "notNull": true, 1227 + "autoincrement": false 1228 + } 1229 + }, 1230 + "indexes": {}, 1231 + "foreignKeys": { 1232 + "theme_policy_available_themes_policy_id_theme_policies_id_fk": { 1233 + "name": "theme_policy_available_themes_policy_id_theme_policies_id_fk", 1234 + "tableFrom": "theme_policy_available_themes", 1235 + "tableTo": "theme_policies", 1236 + "columnsFrom": [ 1237 + "policy_id" 1238 + ], 1239 + "columnsTo": [ 1240 + "id" 1241 + ], 1242 + "onDelete": "cascade", 1243 + "onUpdate": "no action" 1244 + } 1245 + }, 1246 + "compositePrimaryKeys": { 1247 + "theme_policy_available_themes_policy_id_theme_uri_pk": { 1248 + "columns": [ 1249 + "policy_id", 1250 + "theme_uri" 1251 + ], 1252 + "name": "theme_policy_available_themes_policy_id_theme_uri_pk" 1253 + } 1254 + }, 1255 + "uniqueConstraints": {}, 1256 + "checkConstraints": {} 1257 + }, 1258 + "themes": { 1259 + "name": "themes", 1260 + "columns": { 1261 + "id": { 1262 + "name": "id", 1263 + "type": "integer", 1264 + "primaryKey": true, 1265 + "notNull": true, 1266 + "autoincrement": true 1267 + }, 1268 + "did": { 1269 + "name": "did", 1270 + "type": "text", 1271 + "primaryKey": false, 1272 + "notNull": true, 1273 + "autoincrement": false 1274 + }, 1275 + "rkey": { 1276 + "name": "rkey", 1277 + "type": "text", 1278 + "primaryKey": false, 1279 + "notNull": true, 1280 + "autoincrement": false 1281 + }, 1282 + "cid": { 1283 + "name": "cid", 1284 + "type": "text", 1285 + "primaryKey": false, 1286 + "notNull": true, 1287 + "autoincrement": false 1288 + }, 1289 + "name": { 1290 + "name": "name", 1291 + "type": "text", 1292 + "primaryKey": false, 1293 + "notNull": true, 1294 + "autoincrement": false 1295 + }, 1296 + "color_scheme": { 1297 + "name": "color_scheme", 1298 + "type": "text", 1299 + "primaryKey": false, 1300 + "notNull": true, 1301 + "autoincrement": false 1302 + }, 1303 + "tokens": { 1304 + "name": "tokens", 1305 + "type": "text", 1306 + "primaryKey": false, 1307 + "notNull": true, 1308 + "autoincrement": false 1309 + }, 1310 + "css_overrides": { 1311 + "name": "css_overrides", 1312 + "type": "text", 1313 + "primaryKey": false, 1314 + "notNull": false, 1315 + "autoincrement": false 1316 + }, 1317 + "font_urls": { 1318 + "name": "font_urls", 1319 + "type": "text", 1320 + "primaryKey": false, 1321 + "notNull": false, 1322 + "autoincrement": false 1323 + }, 1324 + "created_at": { 1325 + "name": "created_at", 1326 + "type": "integer", 1327 + "primaryKey": false, 1328 + "notNull": true, 1329 + "autoincrement": false 1330 + }, 1331 + "indexed_at": { 1332 + "name": "indexed_at", 1333 + "type": "integer", 1334 + "primaryKey": false, 1335 + "notNull": true, 1336 + "autoincrement": false 1337 + } 1338 + }, 1339 + "indexes": { 1340 + "themes_did_rkey_idx": { 1341 + "name": "themes_did_rkey_idx", 1342 + "columns": [ 1343 + "did", 1344 + "rkey" 1345 + ], 1346 + "isUnique": true 1347 + } 1348 + }, 1349 + "foreignKeys": {}, 1350 + "compositePrimaryKeys": {}, 1351 + "uniqueConstraints": {}, 1352 + "checkConstraints": {} 1353 + }, 1354 + "users": { 1355 + "name": "users", 1356 + "columns": { 1357 + "did": { 1358 + "name": "did", 1359 + "type": "text", 1360 + "primaryKey": true, 1361 + "notNull": true, 1362 + "autoincrement": false 1363 + }, 1364 + "handle": { 1365 + "name": "handle", 1366 + "type": "text", 1367 + "primaryKey": false, 1368 + "notNull": false, 1369 + "autoincrement": false 1370 + }, 1371 + "indexed_at": { 1372 + "name": "indexed_at", 1373 + "type": "integer", 1374 + "primaryKey": false, 1375 + "notNull": true, 1376 + "autoincrement": false 1377 + } 1378 + }, 1379 + "indexes": {}, 1380 + "foreignKeys": {}, 1381 + "compositePrimaryKeys": {}, 1382 + "uniqueConstraints": {}, 1383 + "checkConstraints": {} 1384 + } 1385 + }, 1386 + "views": {}, 1387 + "enums": {}, 1388 + "_meta": { 1389 + "schemas": {}, 1390 + "tables": {}, 1391 + "columns": {} 1392 + }, 1393 + "internal": { 1394 + "indexes": {} 1395 + } 1396 + }
+7
apps/appview/drizzle-sqlite/meta/_journal.json
··· 8 8 "when": 1772035997110, 9 9 "tag": "0000_thankful_mister_fear", 10 10 "breakpoints": true 11 + }, 12 + { 13 + "idx": 1, 14 + "version": "6", 15 + "when": 1772482055726, 16 + "tag": "0001_add_theme_tables", 17 + "breakpoints": true 11 18 } 12 19 ] 13 20 }
+35
apps/appview/drizzle/0013_add_theme_tables.sql
··· 1 + CREATE TABLE "theme_policies" ( 2 + "id" bigserial PRIMARY KEY NOT NULL, 3 + "did" text NOT NULL, 4 + "rkey" text NOT NULL, 5 + "cid" text NOT NULL, 6 + "default_light_theme_uri" text NOT NULL, 7 + "default_dark_theme_uri" text NOT NULL, 8 + "allow_user_choice" boolean NOT NULL, 9 + "indexed_at" timestamp with time zone NOT NULL 10 + ); 11 + --> statement-breakpoint 12 + CREATE TABLE "theme_policy_available_themes" ( 13 + "policy_id" bigint NOT NULL, 14 + "theme_uri" text NOT NULL, 15 + "theme_cid" text NOT NULL, 16 + CONSTRAINT "theme_policy_available_themes_policy_id_theme_uri_pk" PRIMARY KEY("policy_id","theme_uri") 17 + ); 18 + --> statement-breakpoint 19 + CREATE TABLE "themes" ( 20 + "id" bigserial PRIMARY KEY NOT NULL, 21 + "did" text NOT NULL, 22 + "rkey" text NOT NULL, 23 + "cid" text NOT NULL, 24 + "name" text NOT NULL, 25 + "color_scheme" text NOT NULL, 26 + "tokens" jsonb NOT NULL, 27 + "css_overrides" text, 28 + "font_urls" text[], 29 + "created_at" timestamp with time zone NOT NULL, 30 + "indexed_at" timestamp with time zone NOT NULL 31 + ); 32 + --> statement-breakpoint 33 + ALTER TABLE "theme_policy_available_themes" ADD CONSTRAINT "theme_policy_available_themes_policy_id_theme_policies_id_fk" FOREIGN KEY ("policy_id") REFERENCES "public"."theme_policies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 34 + CREATE UNIQUE INDEX "theme_policies_did_rkey_idx" ON "theme_policies" USING btree ("did","rkey");--> statement-breakpoint 35 + CREATE UNIQUE INDEX "themes_did_rkey_idx" ON "themes" USING btree ("did","rkey");
+1526
apps/appview/drizzle/meta/0013_snapshot.json
··· 1 + { 2 + "id": "50219693-221b-49a6-8363-f9de0bf6afdd", 3 + "prevId": "0179c1d9-9fbb-4bc8-9606-475e10ba22dc", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.backfill_errors": { 8 + "name": "backfill_errors", 9 + "schema": "", 10 + "columns": { 11 + "id": { 12 + "name": "id", 13 + "type": "bigserial", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "backfill_id": { 18 + "name": "backfill_id", 19 + "type": "bigint", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "did": { 24 + "name": "did", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "collection": { 30 + "name": "collection", 31 + "type": "text", 32 + "primaryKey": false, 33 + "notNull": true 34 + }, 35 + "error_message": { 36 + "name": "error_message", 37 + "type": "text", 38 + "primaryKey": false, 39 + "notNull": true 40 + }, 41 + "created_at": { 42 + "name": "created_at", 43 + "type": "timestamp with time zone", 44 + "primaryKey": false, 45 + "notNull": true 46 + } 47 + }, 48 + "indexes": { 49 + "backfill_errors_backfill_id_idx": { 50 + "name": "backfill_errors_backfill_id_idx", 51 + "columns": [ 52 + { 53 + "expression": "backfill_id", 54 + "isExpression": false, 55 + "asc": true, 56 + "nulls": "last" 57 + } 58 + ], 59 + "isUnique": false, 60 + "concurrently": false, 61 + "method": "btree", 62 + "with": {} 63 + } 64 + }, 65 + "foreignKeys": { 66 + "backfill_errors_backfill_id_backfill_progress_id_fk": { 67 + "name": "backfill_errors_backfill_id_backfill_progress_id_fk", 68 + "tableFrom": "backfill_errors", 69 + "tableTo": "backfill_progress", 70 + "columnsFrom": [ 71 + "backfill_id" 72 + ], 73 + "columnsTo": [ 74 + "id" 75 + ], 76 + "onDelete": "no action", 77 + "onUpdate": "no action" 78 + } 79 + }, 80 + "compositePrimaryKeys": {}, 81 + "uniqueConstraints": {}, 82 + "policies": {}, 83 + "checkConstraints": {}, 84 + "isRLSEnabled": false 85 + }, 86 + "public.backfill_progress": { 87 + "name": "backfill_progress", 88 + "schema": "", 89 + "columns": { 90 + "id": { 91 + "name": "id", 92 + "type": "bigserial", 93 + "primaryKey": true, 94 + "notNull": true 95 + }, 96 + "status": { 97 + "name": "status", 98 + "type": "text", 99 + "primaryKey": false, 100 + "notNull": true 101 + }, 102 + "backfill_type": { 103 + "name": "backfill_type", 104 + "type": "text", 105 + "primaryKey": false, 106 + "notNull": true 107 + }, 108 + "last_processed_did": { 109 + "name": "last_processed_did", 110 + "type": "text", 111 + "primaryKey": false, 112 + "notNull": false 113 + }, 114 + "dids_total": { 115 + "name": "dids_total", 116 + "type": "integer", 117 + "primaryKey": false, 118 + "notNull": true, 119 + "default": 0 120 + }, 121 + "dids_processed": { 122 + "name": "dids_processed", 123 + "type": "integer", 124 + "primaryKey": false, 125 + "notNull": true, 126 + "default": 0 127 + }, 128 + "records_indexed": { 129 + "name": "records_indexed", 130 + "type": "integer", 131 + "primaryKey": false, 132 + "notNull": true, 133 + "default": 0 134 + }, 135 + "started_at": { 136 + "name": "started_at", 137 + "type": "timestamp with time zone", 138 + "primaryKey": false, 139 + "notNull": true 140 + }, 141 + "completed_at": { 142 + "name": "completed_at", 143 + "type": "timestamp with time zone", 144 + "primaryKey": false, 145 + "notNull": false 146 + }, 147 + "error_message": { 148 + "name": "error_message", 149 + "type": "text", 150 + "primaryKey": false, 151 + "notNull": false 152 + } 153 + }, 154 + "indexes": {}, 155 + "foreignKeys": {}, 156 + "compositePrimaryKeys": {}, 157 + "uniqueConstraints": {}, 158 + "policies": {}, 159 + "checkConstraints": {}, 160 + "isRLSEnabled": false 161 + }, 162 + "public.boards": { 163 + "name": "boards", 164 + "schema": "", 165 + "columns": { 166 + "id": { 167 + "name": "id", 168 + "type": "bigserial", 169 + "primaryKey": true, 170 + "notNull": true 171 + }, 172 + "did": { 173 + "name": "did", 174 + "type": "text", 175 + "primaryKey": false, 176 + "notNull": true 177 + }, 178 + "rkey": { 179 + "name": "rkey", 180 + "type": "text", 181 + "primaryKey": false, 182 + "notNull": true 183 + }, 184 + "cid": { 185 + "name": "cid", 186 + "type": "text", 187 + "primaryKey": false, 188 + "notNull": true 189 + }, 190 + "name": { 191 + "name": "name", 192 + "type": "text", 193 + "primaryKey": false, 194 + "notNull": true 195 + }, 196 + "description": { 197 + "name": "description", 198 + "type": "text", 199 + "primaryKey": false, 200 + "notNull": false 201 + }, 202 + "slug": { 203 + "name": "slug", 204 + "type": "text", 205 + "primaryKey": false, 206 + "notNull": false 207 + }, 208 + "sort_order": { 209 + "name": "sort_order", 210 + "type": "integer", 211 + "primaryKey": false, 212 + "notNull": false 213 + }, 214 + "category_id": { 215 + "name": "category_id", 216 + "type": "bigint", 217 + "primaryKey": false, 218 + "notNull": false 219 + }, 220 + "category_uri": { 221 + "name": "category_uri", 222 + "type": "text", 223 + "primaryKey": false, 224 + "notNull": true 225 + }, 226 + "created_at": { 227 + "name": "created_at", 228 + "type": "timestamp with time zone", 229 + "primaryKey": false, 230 + "notNull": true 231 + }, 232 + "indexed_at": { 233 + "name": "indexed_at", 234 + "type": "timestamp with time zone", 235 + "primaryKey": false, 236 + "notNull": true 237 + } 238 + }, 239 + "indexes": { 240 + "boards_did_rkey_idx": { 241 + "name": "boards_did_rkey_idx", 242 + "columns": [ 243 + { 244 + "expression": "did", 245 + "isExpression": false, 246 + "asc": true, 247 + "nulls": "last" 248 + }, 249 + { 250 + "expression": "rkey", 251 + "isExpression": false, 252 + "asc": true, 253 + "nulls": "last" 254 + } 255 + ], 256 + "isUnique": true, 257 + "concurrently": false, 258 + "method": "btree", 259 + "with": {} 260 + }, 261 + "boards_category_id_idx": { 262 + "name": "boards_category_id_idx", 263 + "columns": [ 264 + { 265 + "expression": "category_id", 266 + "isExpression": false, 267 + "asc": true, 268 + "nulls": "last" 269 + } 270 + ], 271 + "isUnique": false, 272 + "concurrently": false, 273 + "method": "btree", 274 + "with": {} 275 + } 276 + }, 277 + "foreignKeys": { 278 + "boards_category_id_categories_id_fk": { 279 + "name": "boards_category_id_categories_id_fk", 280 + "tableFrom": "boards", 281 + "tableTo": "categories", 282 + "columnsFrom": [ 283 + "category_id" 284 + ], 285 + "columnsTo": [ 286 + "id" 287 + ], 288 + "onDelete": "no action", 289 + "onUpdate": "no action" 290 + } 291 + }, 292 + "compositePrimaryKeys": {}, 293 + "uniqueConstraints": {}, 294 + "policies": {}, 295 + "checkConstraints": {}, 296 + "isRLSEnabled": false 297 + }, 298 + "public.categories": { 299 + "name": "categories", 300 + "schema": "", 301 + "columns": { 302 + "id": { 303 + "name": "id", 304 + "type": "bigserial", 305 + "primaryKey": true, 306 + "notNull": true 307 + }, 308 + "did": { 309 + "name": "did", 310 + "type": "text", 311 + "primaryKey": false, 312 + "notNull": true 313 + }, 314 + "rkey": { 315 + "name": "rkey", 316 + "type": "text", 317 + "primaryKey": false, 318 + "notNull": true 319 + }, 320 + "cid": { 321 + "name": "cid", 322 + "type": "text", 323 + "primaryKey": false, 324 + "notNull": true 325 + }, 326 + "name": { 327 + "name": "name", 328 + "type": "text", 329 + "primaryKey": false, 330 + "notNull": true 331 + }, 332 + "description": { 333 + "name": "description", 334 + "type": "text", 335 + "primaryKey": false, 336 + "notNull": false 337 + }, 338 + "slug": { 339 + "name": "slug", 340 + "type": "text", 341 + "primaryKey": false, 342 + "notNull": false 343 + }, 344 + "sort_order": { 345 + "name": "sort_order", 346 + "type": "integer", 347 + "primaryKey": false, 348 + "notNull": false 349 + }, 350 + "forum_id": { 351 + "name": "forum_id", 352 + "type": "bigint", 353 + "primaryKey": false, 354 + "notNull": false 355 + }, 356 + "created_at": { 357 + "name": "created_at", 358 + "type": "timestamp with time zone", 359 + "primaryKey": false, 360 + "notNull": true 361 + }, 362 + "indexed_at": { 363 + "name": "indexed_at", 364 + "type": "timestamp with time zone", 365 + "primaryKey": false, 366 + "notNull": true 367 + } 368 + }, 369 + "indexes": { 370 + "categories_did_rkey_idx": { 371 + "name": "categories_did_rkey_idx", 372 + "columns": [ 373 + { 374 + "expression": "did", 375 + "isExpression": false, 376 + "asc": true, 377 + "nulls": "last" 378 + }, 379 + { 380 + "expression": "rkey", 381 + "isExpression": false, 382 + "asc": true, 383 + "nulls": "last" 384 + } 385 + ], 386 + "isUnique": true, 387 + "concurrently": false, 388 + "method": "btree", 389 + "with": {} 390 + } 391 + }, 392 + "foreignKeys": { 393 + "categories_forum_id_forums_id_fk": { 394 + "name": "categories_forum_id_forums_id_fk", 395 + "tableFrom": "categories", 396 + "tableTo": "forums", 397 + "columnsFrom": [ 398 + "forum_id" 399 + ], 400 + "columnsTo": [ 401 + "id" 402 + ], 403 + "onDelete": "no action", 404 + "onUpdate": "no action" 405 + } 406 + }, 407 + "compositePrimaryKeys": {}, 408 + "uniqueConstraints": {}, 409 + "policies": {}, 410 + "checkConstraints": {}, 411 + "isRLSEnabled": false 412 + }, 413 + "public.firehose_cursor": { 414 + "name": "firehose_cursor", 415 + "schema": "", 416 + "columns": { 417 + "service": { 418 + "name": "service", 419 + "type": "text", 420 + "primaryKey": true, 421 + "notNull": true, 422 + "default": "'jetstream'" 423 + }, 424 + "cursor": { 425 + "name": "cursor", 426 + "type": "bigint", 427 + "primaryKey": false, 428 + "notNull": true 429 + }, 430 + "updated_at": { 431 + "name": "updated_at", 432 + "type": "timestamp with time zone", 433 + "primaryKey": false, 434 + "notNull": true 435 + } 436 + }, 437 + "indexes": {}, 438 + "foreignKeys": {}, 439 + "compositePrimaryKeys": {}, 440 + "uniqueConstraints": {}, 441 + "policies": {}, 442 + "checkConstraints": {}, 443 + "isRLSEnabled": false 444 + }, 445 + "public.forums": { 446 + "name": "forums", 447 + "schema": "", 448 + "columns": { 449 + "id": { 450 + "name": "id", 451 + "type": "bigserial", 452 + "primaryKey": true, 453 + "notNull": true 454 + }, 455 + "did": { 456 + "name": "did", 457 + "type": "text", 458 + "primaryKey": false, 459 + "notNull": true 460 + }, 461 + "rkey": { 462 + "name": "rkey", 463 + "type": "text", 464 + "primaryKey": false, 465 + "notNull": true 466 + }, 467 + "cid": { 468 + "name": "cid", 469 + "type": "text", 470 + "primaryKey": false, 471 + "notNull": true 472 + }, 473 + "name": { 474 + "name": "name", 475 + "type": "text", 476 + "primaryKey": false, 477 + "notNull": true 478 + }, 479 + "description": { 480 + "name": "description", 481 + "type": "text", 482 + "primaryKey": false, 483 + "notNull": false 484 + }, 485 + "indexed_at": { 486 + "name": "indexed_at", 487 + "type": "timestamp with time zone", 488 + "primaryKey": false, 489 + "notNull": true 490 + } 491 + }, 492 + "indexes": { 493 + "forums_did_rkey_idx": { 494 + "name": "forums_did_rkey_idx", 495 + "columns": [ 496 + { 497 + "expression": "did", 498 + "isExpression": false, 499 + "asc": true, 500 + "nulls": "last" 501 + }, 502 + { 503 + "expression": "rkey", 504 + "isExpression": false, 505 + "asc": true, 506 + "nulls": "last" 507 + } 508 + ], 509 + "isUnique": true, 510 + "concurrently": false, 511 + "method": "btree", 512 + "with": {} 513 + } 514 + }, 515 + "foreignKeys": {}, 516 + "compositePrimaryKeys": {}, 517 + "uniqueConstraints": {}, 518 + "policies": {}, 519 + "checkConstraints": {}, 520 + "isRLSEnabled": false 521 + }, 522 + "public.memberships": { 523 + "name": "memberships", 524 + "schema": "", 525 + "columns": { 526 + "id": { 527 + "name": "id", 528 + "type": "bigserial", 529 + "primaryKey": true, 530 + "notNull": true 531 + }, 532 + "did": { 533 + "name": "did", 534 + "type": "text", 535 + "primaryKey": false, 536 + "notNull": true 537 + }, 538 + "rkey": { 539 + "name": "rkey", 540 + "type": "text", 541 + "primaryKey": false, 542 + "notNull": true 543 + }, 544 + "cid": { 545 + "name": "cid", 546 + "type": "text", 547 + "primaryKey": false, 548 + "notNull": true 549 + }, 550 + "forum_id": { 551 + "name": "forum_id", 552 + "type": "bigint", 553 + "primaryKey": false, 554 + "notNull": false 555 + }, 556 + "forum_uri": { 557 + "name": "forum_uri", 558 + "type": "text", 559 + "primaryKey": false, 560 + "notNull": true 561 + }, 562 + "role": { 563 + "name": "role", 564 + "type": "text", 565 + "primaryKey": false, 566 + "notNull": false 567 + }, 568 + "role_uri": { 569 + "name": "role_uri", 570 + "type": "text", 571 + "primaryKey": false, 572 + "notNull": false 573 + }, 574 + "joined_at": { 575 + "name": "joined_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 + "memberships_did_rkey_idx": { 595 + "name": "memberships_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 + "memberships_did_idx": { 616 + "name": "memberships_did_idx", 617 + "columns": [ 618 + { 619 + "expression": "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 + "memberships_did_users_did_fk": { 633 + "name": "memberships_did_users_did_fk", 634 + "tableFrom": "memberships", 635 + "tableTo": "users", 636 + "columnsFrom": [ 637 + "did" 638 + ], 639 + "columnsTo": [ 640 + "did" 641 + ], 642 + "onDelete": "no action", 643 + "onUpdate": "no action" 644 + }, 645 + "memberships_forum_id_forums_id_fk": { 646 + "name": "memberships_forum_id_forums_id_fk", 647 + "tableFrom": "memberships", 648 + "tableTo": "forums", 649 + "columnsFrom": [ 650 + "forum_id" 651 + ], 652 + "columnsTo": [ 653 + "id" 654 + ], 655 + "onDelete": "no action", 656 + "onUpdate": "no action" 657 + } 658 + }, 659 + "compositePrimaryKeys": {}, 660 + "uniqueConstraints": {}, 661 + "policies": {}, 662 + "checkConstraints": {}, 663 + "isRLSEnabled": false 664 + }, 665 + "public.mod_actions": { 666 + "name": "mod_actions", 667 + "schema": "", 668 + "columns": { 669 + "id": { 670 + "name": "id", 671 + "type": "bigserial", 672 + "primaryKey": true, 673 + "notNull": true 674 + }, 675 + "did": { 676 + "name": "did", 677 + "type": "text", 678 + "primaryKey": false, 679 + "notNull": true 680 + }, 681 + "rkey": { 682 + "name": "rkey", 683 + "type": "text", 684 + "primaryKey": false, 685 + "notNull": true 686 + }, 687 + "cid": { 688 + "name": "cid", 689 + "type": "text", 690 + "primaryKey": false, 691 + "notNull": true 692 + }, 693 + "action": { 694 + "name": "action", 695 + "type": "text", 696 + "primaryKey": false, 697 + "notNull": true 698 + }, 699 + "subject_did": { 700 + "name": "subject_did", 701 + "type": "text", 702 + "primaryKey": false, 703 + "notNull": false 704 + }, 705 + "subject_post_uri": { 706 + "name": "subject_post_uri", 707 + "type": "text", 708 + "primaryKey": false, 709 + "notNull": false 710 + }, 711 + "forum_id": { 712 + "name": "forum_id", 713 + "type": "bigint", 714 + "primaryKey": false, 715 + "notNull": false 716 + }, 717 + "reason": { 718 + "name": "reason", 719 + "type": "text", 720 + "primaryKey": false, 721 + "notNull": false 722 + }, 723 + "created_by": { 724 + "name": "created_by", 725 + "type": "text", 726 + "primaryKey": false, 727 + "notNull": true 728 + }, 729 + "expires_at": { 730 + "name": "expires_at", 731 + "type": "timestamp with time zone", 732 + "primaryKey": false, 733 + "notNull": false 734 + }, 735 + "created_at": { 736 + "name": "created_at", 737 + "type": "timestamp with time zone", 738 + "primaryKey": false, 739 + "notNull": true 740 + }, 741 + "indexed_at": { 742 + "name": "indexed_at", 743 + "type": "timestamp with time zone", 744 + "primaryKey": false, 745 + "notNull": true 746 + } 747 + }, 748 + "indexes": { 749 + "mod_actions_did_rkey_idx": { 750 + "name": "mod_actions_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 + "mod_actions_subject_did_idx": { 771 + "name": "mod_actions_subject_did_idx", 772 + "columns": [ 773 + { 774 + "expression": "subject_did", 775 + "isExpression": false, 776 + "asc": true, 777 + "nulls": "last" 778 + } 779 + ], 780 + "isUnique": false, 781 + "concurrently": false, 782 + "method": "btree", 783 + "with": {} 784 + }, 785 + "mod_actions_subject_post_uri_idx": { 786 + "name": "mod_actions_subject_post_uri_idx", 787 + "columns": [ 788 + { 789 + "expression": "subject_post_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 + }, 801 + "foreignKeys": { 802 + "mod_actions_forum_id_forums_id_fk": { 803 + "name": "mod_actions_forum_id_forums_id_fk", 804 + "tableFrom": "mod_actions", 805 + "tableTo": "forums", 806 + "columnsFrom": [ 807 + "forum_id" 808 + ], 809 + "columnsTo": [ 810 + "id" 811 + ], 812 + "onDelete": "no action", 813 + "onUpdate": "no action" 814 + } 815 + }, 816 + "compositePrimaryKeys": {}, 817 + "uniqueConstraints": {}, 818 + "policies": {}, 819 + "checkConstraints": {}, 820 + "isRLSEnabled": false 821 + }, 822 + "public.posts": { 823 + "name": "posts", 824 + "schema": "", 825 + "columns": { 826 + "id": { 827 + "name": "id", 828 + "type": "bigserial", 829 + "primaryKey": true, 830 + "notNull": true 831 + }, 832 + "did": { 833 + "name": "did", 834 + "type": "text", 835 + "primaryKey": false, 836 + "notNull": true 837 + }, 838 + "rkey": { 839 + "name": "rkey", 840 + "type": "text", 841 + "primaryKey": false, 842 + "notNull": true 843 + }, 844 + "cid": { 845 + "name": "cid", 846 + "type": "text", 847 + "primaryKey": false, 848 + "notNull": true 849 + }, 850 + "title": { 851 + "name": "title", 852 + "type": "text", 853 + "primaryKey": false, 854 + "notNull": false 855 + }, 856 + "text": { 857 + "name": "text", 858 + "type": "text", 859 + "primaryKey": false, 860 + "notNull": true 861 + }, 862 + "forum_uri": { 863 + "name": "forum_uri", 864 + "type": "text", 865 + "primaryKey": false, 866 + "notNull": false 867 + }, 868 + "board_uri": { 869 + "name": "board_uri", 870 + "type": "text", 871 + "primaryKey": false, 872 + "notNull": false 873 + }, 874 + "board_id": { 875 + "name": "board_id", 876 + "type": "bigint", 877 + "primaryKey": false, 878 + "notNull": false 879 + }, 880 + "root_post_id": { 881 + "name": "root_post_id", 882 + "type": "bigint", 883 + "primaryKey": false, 884 + "notNull": false 885 + }, 886 + "parent_post_id": { 887 + "name": "parent_post_id", 888 + "type": "bigint", 889 + "primaryKey": false, 890 + "notNull": false 891 + }, 892 + "root_uri": { 893 + "name": "root_uri", 894 + "type": "text", 895 + "primaryKey": false, 896 + "notNull": false 897 + }, 898 + "parent_uri": { 899 + "name": "parent_uri", 900 + "type": "text", 901 + "primaryKey": false, 902 + "notNull": false 903 + }, 904 + "created_at": { 905 + "name": "created_at", 906 + "type": "timestamp with time zone", 907 + "primaryKey": false, 908 + "notNull": true 909 + }, 910 + "indexed_at": { 911 + "name": "indexed_at", 912 + "type": "timestamp with time zone", 913 + "primaryKey": false, 914 + "notNull": true 915 + }, 916 + "banned_by_mod": { 917 + "name": "banned_by_mod", 918 + "type": "boolean", 919 + "primaryKey": false, 920 + "notNull": true, 921 + "default": false 922 + }, 923 + "deleted_by_user": { 924 + "name": "deleted_by_user", 925 + "type": "boolean", 926 + "primaryKey": false, 927 + "notNull": true, 928 + "default": false 929 + } 930 + }, 931 + "indexes": { 932 + "posts_did_rkey_idx": { 933 + "name": "posts_did_rkey_idx", 934 + "columns": [ 935 + { 936 + "expression": "did", 937 + "isExpression": false, 938 + "asc": true, 939 + "nulls": "last" 940 + }, 941 + { 942 + "expression": "rkey", 943 + "isExpression": false, 944 + "asc": true, 945 + "nulls": "last" 946 + } 947 + ], 948 + "isUnique": true, 949 + "concurrently": false, 950 + "method": "btree", 951 + "with": {} 952 + }, 953 + "posts_forum_uri_idx": { 954 + "name": "posts_forum_uri_idx", 955 + "columns": [ 956 + { 957 + "expression": "forum_uri", 958 + "isExpression": false, 959 + "asc": true, 960 + "nulls": "last" 961 + } 962 + ], 963 + "isUnique": false, 964 + "concurrently": false, 965 + "method": "btree", 966 + "with": {} 967 + }, 968 + "posts_board_id_idx": { 969 + "name": "posts_board_id_idx", 970 + "columns": [ 971 + { 972 + "expression": "board_id", 973 + "isExpression": false, 974 + "asc": true, 975 + "nulls": "last" 976 + } 977 + ], 978 + "isUnique": false, 979 + "concurrently": false, 980 + "method": "btree", 981 + "with": {} 982 + }, 983 + "posts_board_uri_idx": { 984 + "name": "posts_board_uri_idx", 985 + "columns": [ 986 + { 987 + "expression": "board_uri", 988 + "isExpression": false, 989 + "asc": true, 990 + "nulls": "last" 991 + } 992 + ], 993 + "isUnique": false, 994 + "concurrently": false, 995 + "method": "btree", 996 + "with": {} 997 + }, 998 + "posts_root_post_id_idx": { 999 + "name": "posts_root_post_id_idx", 1000 + "columns": [ 1001 + { 1002 + "expression": "root_post_id", 1003 + "isExpression": false, 1004 + "asc": true, 1005 + "nulls": "last" 1006 + } 1007 + ], 1008 + "isUnique": false, 1009 + "concurrently": false, 1010 + "method": "btree", 1011 + "with": {} 1012 + } 1013 + }, 1014 + "foreignKeys": { 1015 + "posts_did_users_did_fk": { 1016 + "name": "posts_did_users_did_fk", 1017 + "tableFrom": "posts", 1018 + "tableTo": "users", 1019 + "columnsFrom": [ 1020 + "did" 1021 + ], 1022 + "columnsTo": [ 1023 + "did" 1024 + ], 1025 + "onDelete": "no action", 1026 + "onUpdate": "no action" 1027 + }, 1028 + "posts_board_id_boards_id_fk": { 1029 + "name": "posts_board_id_boards_id_fk", 1030 + "tableFrom": "posts", 1031 + "tableTo": "boards", 1032 + "columnsFrom": [ 1033 + "board_id" 1034 + ], 1035 + "columnsTo": [ 1036 + "id" 1037 + ], 1038 + "onDelete": "no action", 1039 + "onUpdate": "no action" 1040 + }, 1041 + "posts_root_post_id_posts_id_fk": { 1042 + "name": "posts_root_post_id_posts_id_fk", 1043 + "tableFrom": "posts", 1044 + "tableTo": "posts", 1045 + "columnsFrom": [ 1046 + "root_post_id" 1047 + ], 1048 + "columnsTo": [ 1049 + "id" 1050 + ], 1051 + "onDelete": "no action", 1052 + "onUpdate": "no action" 1053 + }, 1054 + "posts_parent_post_id_posts_id_fk": { 1055 + "name": "posts_parent_post_id_posts_id_fk", 1056 + "tableFrom": "posts", 1057 + "tableTo": "posts", 1058 + "columnsFrom": [ 1059 + "parent_post_id" 1060 + ], 1061 + "columnsTo": [ 1062 + "id" 1063 + ], 1064 + "onDelete": "no action", 1065 + "onUpdate": "no action" 1066 + } 1067 + }, 1068 + "compositePrimaryKeys": {}, 1069 + "uniqueConstraints": {}, 1070 + "policies": {}, 1071 + "checkConstraints": {}, 1072 + "isRLSEnabled": false 1073 + }, 1074 + "public.role_permissions": { 1075 + "name": "role_permissions", 1076 + "schema": "", 1077 + "columns": { 1078 + "role_id": { 1079 + "name": "role_id", 1080 + "type": "bigint", 1081 + "primaryKey": false, 1082 + "notNull": true 1083 + }, 1084 + "permission": { 1085 + "name": "permission", 1086 + "type": "text", 1087 + "primaryKey": false, 1088 + "notNull": true 1089 + } 1090 + }, 1091 + "indexes": {}, 1092 + "foreignKeys": { 1093 + "role_permissions_role_id_roles_id_fk": { 1094 + "name": "role_permissions_role_id_roles_id_fk", 1095 + "tableFrom": "role_permissions", 1096 + "tableTo": "roles", 1097 + "columnsFrom": [ 1098 + "role_id" 1099 + ], 1100 + "columnsTo": [ 1101 + "id" 1102 + ], 1103 + "onDelete": "cascade", 1104 + "onUpdate": "no action" 1105 + } 1106 + }, 1107 + "compositePrimaryKeys": { 1108 + "role_permissions_role_id_permission_pk": { 1109 + "name": "role_permissions_role_id_permission_pk", 1110 + "columns": [ 1111 + "role_id", 1112 + "permission" 1113 + ] 1114 + } 1115 + }, 1116 + "uniqueConstraints": {}, 1117 + "policies": {}, 1118 + "checkConstraints": {}, 1119 + "isRLSEnabled": false 1120 + }, 1121 + "public.roles": { 1122 + "name": "roles", 1123 + "schema": "", 1124 + "columns": { 1125 + "id": { 1126 + "name": "id", 1127 + "type": "bigserial", 1128 + "primaryKey": true, 1129 + "notNull": true 1130 + }, 1131 + "did": { 1132 + "name": "did", 1133 + "type": "text", 1134 + "primaryKey": false, 1135 + "notNull": true 1136 + }, 1137 + "rkey": { 1138 + "name": "rkey", 1139 + "type": "text", 1140 + "primaryKey": false, 1141 + "notNull": true 1142 + }, 1143 + "cid": { 1144 + "name": "cid", 1145 + "type": "text", 1146 + "primaryKey": false, 1147 + "notNull": true 1148 + }, 1149 + "name": { 1150 + "name": "name", 1151 + "type": "text", 1152 + "primaryKey": false, 1153 + "notNull": true 1154 + }, 1155 + "description": { 1156 + "name": "description", 1157 + "type": "text", 1158 + "primaryKey": false, 1159 + "notNull": false 1160 + }, 1161 + "priority": { 1162 + "name": "priority", 1163 + "type": "integer", 1164 + "primaryKey": false, 1165 + "notNull": true 1166 + }, 1167 + "created_at": { 1168 + "name": "created_at", 1169 + "type": "timestamp with time zone", 1170 + "primaryKey": false, 1171 + "notNull": true 1172 + }, 1173 + "indexed_at": { 1174 + "name": "indexed_at", 1175 + "type": "timestamp with time zone", 1176 + "primaryKey": false, 1177 + "notNull": true 1178 + } 1179 + }, 1180 + "indexes": { 1181 + "roles_did_rkey_idx": { 1182 + "name": "roles_did_rkey_idx", 1183 + "columns": [ 1184 + { 1185 + "expression": "did", 1186 + "isExpression": false, 1187 + "asc": true, 1188 + "nulls": "last" 1189 + }, 1190 + { 1191 + "expression": "rkey", 1192 + "isExpression": false, 1193 + "asc": true, 1194 + "nulls": "last" 1195 + } 1196 + ], 1197 + "isUnique": true, 1198 + "concurrently": false, 1199 + "method": "btree", 1200 + "with": {} 1201 + }, 1202 + "roles_did_idx": { 1203 + "name": "roles_did_idx", 1204 + "columns": [ 1205 + { 1206 + "expression": "did", 1207 + "isExpression": false, 1208 + "asc": true, 1209 + "nulls": "last" 1210 + } 1211 + ], 1212 + "isUnique": false, 1213 + "concurrently": false, 1214 + "method": "btree", 1215 + "with": {} 1216 + }, 1217 + "roles_did_name_idx": { 1218 + "name": "roles_did_name_idx", 1219 + "columns": [ 1220 + { 1221 + "expression": "did", 1222 + "isExpression": false, 1223 + "asc": true, 1224 + "nulls": "last" 1225 + }, 1226 + { 1227 + "expression": "name", 1228 + "isExpression": false, 1229 + "asc": true, 1230 + "nulls": "last" 1231 + } 1232 + ], 1233 + "isUnique": false, 1234 + "concurrently": false, 1235 + "method": "btree", 1236 + "with": {} 1237 + } 1238 + }, 1239 + "foreignKeys": {}, 1240 + "compositePrimaryKeys": {}, 1241 + "uniqueConstraints": {}, 1242 + "policies": {}, 1243 + "checkConstraints": {}, 1244 + "isRLSEnabled": false 1245 + }, 1246 + "public.theme_policies": { 1247 + "name": "theme_policies", 1248 + "schema": "", 1249 + "columns": { 1250 + "id": { 1251 + "name": "id", 1252 + "type": "bigserial", 1253 + "primaryKey": true, 1254 + "notNull": true 1255 + }, 1256 + "did": { 1257 + "name": "did", 1258 + "type": "text", 1259 + "primaryKey": false, 1260 + "notNull": true 1261 + }, 1262 + "rkey": { 1263 + "name": "rkey", 1264 + "type": "text", 1265 + "primaryKey": false, 1266 + "notNull": true 1267 + }, 1268 + "cid": { 1269 + "name": "cid", 1270 + "type": "text", 1271 + "primaryKey": false, 1272 + "notNull": true 1273 + }, 1274 + "default_light_theme_uri": { 1275 + "name": "default_light_theme_uri", 1276 + "type": "text", 1277 + "primaryKey": false, 1278 + "notNull": true 1279 + }, 1280 + "default_dark_theme_uri": { 1281 + "name": "default_dark_theme_uri", 1282 + "type": "text", 1283 + "primaryKey": false, 1284 + "notNull": true 1285 + }, 1286 + "allow_user_choice": { 1287 + "name": "allow_user_choice", 1288 + "type": "boolean", 1289 + "primaryKey": false, 1290 + "notNull": true 1291 + }, 1292 + "indexed_at": { 1293 + "name": "indexed_at", 1294 + "type": "timestamp with time zone", 1295 + "primaryKey": false, 1296 + "notNull": true 1297 + } 1298 + }, 1299 + "indexes": { 1300 + "theme_policies_did_rkey_idx": { 1301 + "name": "theme_policies_did_rkey_idx", 1302 + "columns": [ 1303 + { 1304 + "expression": "did", 1305 + "isExpression": false, 1306 + "asc": true, 1307 + "nulls": "last" 1308 + }, 1309 + { 1310 + "expression": "rkey", 1311 + "isExpression": false, 1312 + "asc": true, 1313 + "nulls": "last" 1314 + } 1315 + ], 1316 + "isUnique": true, 1317 + "concurrently": false, 1318 + "method": "btree", 1319 + "with": {} 1320 + } 1321 + }, 1322 + "foreignKeys": {}, 1323 + "compositePrimaryKeys": {}, 1324 + "uniqueConstraints": {}, 1325 + "policies": {}, 1326 + "checkConstraints": {}, 1327 + "isRLSEnabled": false 1328 + }, 1329 + "public.theme_policy_available_themes": { 1330 + "name": "theme_policy_available_themes", 1331 + "schema": "", 1332 + "columns": { 1333 + "policy_id": { 1334 + "name": "policy_id", 1335 + "type": "bigint", 1336 + "primaryKey": false, 1337 + "notNull": true 1338 + }, 1339 + "theme_uri": { 1340 + "name": "theme_uri", 1341 + "type": "text", 1342 + "primaryKey": false, 1343 + "notNull": true 1344 + }, 1345 + "theme_cid": { 1346 + "name": "theme_cid", 1347 + "type": "text", 1348 + "primaryKey": false, 1349 + "notNull": true 1350 + } 1351 + }, 1352 + "indexes": {}, 1353 + "foreignKeys": { 1354 + "theme_policy_available_themes_policy_id_theme_policies_id_fk": { 1355 + "name": "theme_policy_available_themes_policy_id_theme_policies_id_fk", 1356 + "tableFrom": "theme_policy_available_themes", 1357 + "tableTo": "theme_policies", 1358 + "columnsFrom": [ 1359 + "policy_id" 1360 + ], 1361 + "columnsTo": [ 1362 + "id" 1363 + ], 1364 + "onDelete": "cascade", 1365 + "onUpdate": "no action" 1366 + } 1367 + }, 1368 + "compositePrimaryKeys": { 1369 + "theme_policy_available_themes_policy_id_theme_uri_pk": { 1370 + "name": "theme_policy_available_themes_policy_id_theme_uri_pk", 1371 + "columns": [ 1372 + "policy_id", 1373 + "theme_uri" 1374 + ] 1375 + } 1376 + }, 1377 + "uniqueConstraints": {}, 1378 + "policies": {}, 1379 + "checkConstraints": {}, 1380 + "isRLSEnabled": false 1381 + }, 1382 + "public.themes": { 1383 + "name": "themes", 1384 + "schema": "", 1385 + "columns": { 1386 + "id": { 1387 + "name": "id", 1388 + "type": "bigserial", 1389 + "primaryKey": true, 1390 + "notNull": true 1391 + }, 1392 + "did": { 1393 + "name": "did", 1394 + "type": "text", 1395 + "primaryKey": false, 1396 + "notNull": true 1397 + }, 1398 + "rkey": { 1399 + "name": "rkey", 1400 + "type": "text", 1401 + "primaryKey": false, 1402 + "notNull": true 1403 + }, 1404 + "cid": { 1405 + "name": "cid", 1406 + "type": "text", 1407 + "primaryKey": false, 1408 + "notNull": true 1409 + }, 1410 + "name": { 1411 + "name": "name", 1412 + "type": "text", 1413 + "primaryKey": false, 1414 + "notNull": true 1415 + }, 1416 + "color_scheme": { 1417 + "name": "color_scheme", 1418 + "type": "text", 1419 + "primaryKey": false, 1420 + "notNull": true 1421 + }, 1422 + "tokens": { 1423 + "name": "tokens", 1424 + "type": "jsonb", 1425 + "primaryKey": false, 1426 + "notNull": true 1427 + }, 1428 + "css_overrides": { 1429 + "name": "css_overrides", 1430 + "type": "text", 1431 + "primaryKey": false, 1432 + "notNull": false 1433 + }, 1434 + "font_urls": { 1435 + "name": "font_urls", 1436 + "type": "text[]", 1437 + "primaryKey": false, 1438 + "notNull": false 1439 + }, 1440 + "created_at": { 1441 + "name": "created_at", 1442 + "type": "timestamp with time zone", 1443 + "primaryKey": false, 1444 + "notNull": true 1445 + }, 1446 + "indexed_at": { 1447 + "name": "indexed_at", 1448 + "type": "timestamp with time zone", 1449 + "primaryKey": false, 1450 + "notNull": true 1451 + } 1452 + }, 1453 + "indexes": { 1454 + "themes_did_rkey_idx": { 1455 + "name": "themes_did_rkey_idx", 1456 + "columns": [ 1457 + { 1458 + "expression": "did", 1459 + "isExpression": false, 1460 + "asc": true, 1461 + "nulls": "last" 1462 + }, 1463 + { 1464 + "expression": "rkey", 1465 + "isExpression": false, 1466 + "asc": true, 1467 + "nulls": "last" 1468 + } 1469 + ], 1470 + "isUnique": true, 1471 + "concurrently": false, 1472 + "method": "btree", 1473 + "with": {} 1474 + } 1475 + }, 1476 + "foreignKeys": {}, 1477 + "compositePrimaryKeys": {}, 1478 + "uniqueConstraints": {}, 1479 + "policies": {}, 1480 + "checkConstraints": {}, 1481 + "isRLSEnabled": false 1482 + }, 1483 + "public.users": { 1484 + "name": "users", 1485 + "schema": "", 1486 + "columns": { 1487 + "did": { 1488 + "name": "did", 1489 + "type": "text", 1490 + "primaryKey": true, 1491 + "notNull": true 1492 + }, 1493 + "handle": { 1494 + "name": "handle", 1495 + "type": "text", 1496 + "primaryKey": false, 1497 + "notNull": false 1498 + }, 1499 + "indexed_at": { 1500 + "name": "indexed_at", 1501 + "type": "timestamp with time zone", 1502 + "primaryKey": false, 1503 + "notNull": true 1504 + } 1505 + }, 1506 + "indexes": {}, 1507 + "foreignKeys": {}, 1508 + "compositePrimaryKeys": {}, 1509 + "uniqueConstraints": {}, 1510 + "policies": {}, 1511 + "checkConstraints": {}, 1512 + "isRLSEnabled": false 1513 + } 1514 + }, 1515 + "enums": {}, 1516 + "schemas": {}, 1517 + "sequences": {}, 1518 + "roles": {}, 1519 + "policies": {}, 1520 + "views": {}, 1521 + "_meta": { 1522 + "columns": {}, 1523 + "schemas": {}, 1524 + "tables": {} 1525 + } 1526 + }
+7
apps/appview/drizzle/meta/_journal.json
··· 92 92 "when": 1772035630111, 93 93 "tag": "0012_acoustic_swordsman", 94 94 "breakpoints": true 95 + }, 96 + { 97 + "idx": 13, 98 + "version": "7", 99 + "when": 1772482052223, 100 + "tag": "0013_add_theme_tables", 101 + "breakpoints": true 95 102 } 96 103 ] 97 104 }
+9 -1
apps/appview/src/lib/__tests__/test-context.ts
··· 1 1 import { eq, or, like } from "drizzle-orm"; 2 2 import { createDb, runSqliteMigrations } from "@atbb/db"; 3 - import { forums, posts, users, categories, memberships, boards, roles, modActions, backfillProgress, backfillErrors } from "@atbb/db"; 3 + import { forums, posts, users, categories, memberships, boards, roles, modActions, backfillProgress, backfillErrors, themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 4 4 import { createLogger } from "@atbb/logger"; 5 5 import path from "path"; 6 6 import { fileURLToPath } from "url"; ··· 92 92 await db.delete(modActions).catch(() => {}); 93 93 await db.delete(backfillErrors).catch(() => {}); 94 94 await db.delete(backfillProgress).catch(() => {}); 95 + await db.delete(themePolicyAvailableThemes).catch(() => {}); 96 + await db.delete(themePolicies).catch(() => {}); // cascades to theme_policy_available_themes 97 + await db.delete(themes).catch(() => {}); 95 98 await db.delete(forums).catch(() => {}); 96 99 return; 97 100 } ··· 109 112 await db.delete(modActions).where(eq(modActions.did, config.forumDid)).catch(() => {}); 110 113 await db.delete(backfillErrors).catch(() => {}); 111 114 await db.delete(backfillProgress).catch(() => {}); 115 + // Deleting themePolicies cascades to theme_policy_available_themes 116 + await db.delete(themePolicies).where(eq(themePolicies.did, config.forumDid)).catch(() => {}); 117 + await db.delete(themes).where(eq(themes.did, config.forumDid)).catch(() => {}); 112 118 await db.delete(forums).where(eq(forums.did, config.forumDid)).catch(() => {}); 113 119 }; 114 120 ··· 181 187 await db.delete(boards).where(eq(boards.did, config.forumDid)); 182 188 await db.delete(categories).where(eq(categories.did, config.forumDid)); 183 189 await db.delete(roles).where(eq(roles.did, config.forumDid)); // cascades to role_permissions 190 + await db.delete(themePolicies).where(eq(themePolicies.did, config.forumDid)); 191 + await db.delete(themes).where(eq(themes.did, config.forumDid)); 184 192 await db.delete(modActions).where(eq(modActions.did, config.forumDid)); 185 193 await db.delete(backfillErrors).catch(() => {}); 186 194 await db.delete(backfillProgress).catch(() => {});
+12
apps/appview/src/lib/firehose.ts
··· 141 141 onCreate: this.createWrappedHandler("handleReactionCreate"), 142 142 onUpdate: this.createWrappedHandler("handleReactionUpdate"), 143 143 onDelete: this.createWrappedHandler("handleReactionDelete"), 144 + }) 145 + .register({ 146 + collection: "space.atbb.forum.theme", 147 + onCreate: this.createWrappedHandler("handleThemeCreate"), 148 + onUpdate: this.createWrappedHandler("handleThemeUpdate"), 149 + onDelete: this.createWrappedHandler("handleThemeDelete"), 150 + }) 151 + .register({ 152 + collection: "space.atbb.forum.themePolicy", 153 + onCreate: this.createWrappedHandler("handleThemePolicyCreate"), 154 + onUpdate: this.createWrappedHandler("handleThemePolicyUpdate"), 155 + onDelete: this.createWrappedHandler("handleThemePolicyDelete"), 144 156 }); 145 157 } 146 158
+99
apps/appview/src/lib/indexer.ts
··· 15 15 modActions, 16 16 roles, 17 17 rolePermissions, 18 + themes, 19 + themePolicies, 20 + themePolicyAvailableThemes, 18 21 } from "@atbb/db"; 19 22 import { eq, and } from "drizzle-orm"; 20 23 import { parseAtUri } from "./at-uri.js"; ··· 27 30 SpaceAtbbMembership as Membership, 28 31 SpaceAtbbModAction as ModAction, 29 32 SpaceAtbbForumRole as Role, 33 + SpaceAtbbForumTheme as Theme, 34 + SpaceAtbbForumThemePolicy as ThemePolicy, 30 35 } from "@atbb/lexicon"; 31 36 32 37 // ── Collection Config Types ───────────────────────────── ··· 354 359 }, 355 360 }; 356 361 362 + private themeConfig: CollectionConfig<Theme.Record> = { 363 + name: "Theme", 364 + table: themes, 365 + deleteStrategy: "hard", 366 + toInsertValues: async (event, record) => ({ 367 + did: event.did, 368 + rkey: event.commit.rkey, 369 + cid: event.commit.cid, 370 + name: record.name, 371 + colorScheme: record.colorScheme as string, 372 + tokens: record.tokens, 373 + cssOverrides: (record.cssOverrides as string | undefined) ?? null, 374 + fontUrls: (record.fontUrls as string[] | undefined) ?? null, 375 + createdAt: new Date(record.createdAt as string), 376 + indexedAt: new Date(), 377 + }), 378 + toUpdateValues: async (event, record) => ({ 379 + cid: event.commit.cid, 380 + name: record.name, 381 + colorScheme: record.colorScheme as string, 382 + tokens: record.tokens, 383 + cssOverrides: (record.cssOverrides as string | undefined) ?? null, 384 + fontUrls: (record.fontUrls as string[] | undefined) ?? null, 385 + indexedAt: new Date(), 386 + }), 387 + }; 388 + 389 + private themePolicyConfig: CollectionConfig<ThemePolicy.Record> = { 390 + name: "ThemePolicy", 391 + table: themePolicies, 392 + deleteStrategy: "hard", 393 + toInsertValues: async (event, record) => ({ 394 + did: event.did, 395 + rkey: event.commit.rkey, 396 + cid: event.commit.cid, 397 + defaultLightThemeUri: record.defaultLightTheme.theme.uri, 398 + defaultDarkThemeUri: record.defaultDarkTheme.theme.uri, 399 + allowUserChoice: record.allowUserChoice, 400 + indexedAt: new Date(), 401 + }), 402 + toUpdateValues: async (event, record) => ({ 403 + cid: event.commit.cid, 404 + defaultLightThemeUri: record.defaultLightTheme.theme.uri, 405 + defaultDarkThemeUri: record.defaultDarkTheme.theme.uri, 406 + allowUserChoice: record.allowUserChoice, 407 + indexedAt: new Date(), 408 + }), 409 + afterUpsert: async (_event, record, policyId, tx) => { 410 + // Atomically replace all available-theme rows for this policy 411 + await tx 412 + .delete(themePolicyAvailableThemes) 413 + .where(eq(themePolicyAvailableThemes.policyId, policyId)); 414 + 415 + const available = record.availableThemes ?? []; 416 + if (available.length > 0) { 417 + await tx.insert(themePolicyAvailableThemes).values( 418 + available.map((themeRef) => ({ 419 + policyId, 420 + themeUri: themeRef.theme.uri, 421 + themeCid: themeRef.theme.cid, 422 + })) 423 + ); 424 + } 425 + }, 426 + }; 427 + 357 428 private membershipConfig: CollectionConfig<Membership.Record> = { 358 429 name: "Membership", 359 430 table: memberships, ··· 739 810 740 811 async handleRoleDelete(event: CommitDeleteEvent<"space.atbb.forum.role">) { 741 812 await this.genericDelete(this.roleConfig, event); 813 + } 814 + 815 + // ── Theme Handlers ────────────────────────────────────── 816 + 817 + async handleThemeCreate(event: CommitCreateEvent<"space.atbb.forum.theme">) { 818 + await this.genericCreate(this.themeConfig, event); 819 + } 820 + 821 + async handleThemeUpdate(event: CommitUpdateEvent<"space.atbb.forum.theme">) { 822 + await this.genericUpdate(this.themeConfig, event); 823 + } 824 + 825 + async handleThemeDelete(event: CommitDeleteEvent<"space.atbb.forum.theme">) { 826 + await this.genericDelete(this.themeConfig, event); 827 + } 828 + 829 + // ── ThemePolicy Handlers ───────────────────────────────── 830 + 831 + async handleThemePolicyCreate(event: CommitCreateEvent<"space.atbb.forum.themePolicy">) { 832 + await this.genericCreate(this.themePolicyConfig, event); 833 + } 834 + 835 + async handleThemePolicyUpdate(event: CommitUpdateEvent<"space.atbb.forum.themePolicy">) { 836 + await this.genericUpdate(this.themePolicyConfig, event); 837 + } 838 + 839 + async handleThemePolicyDelete(event: CommitDeleteEvent<"space.atbb.forum.themePolicy">) { 840 + await this.genericDelete(this.themePolicyConfig, event); 742 841 } 743 842 744 843 // ── Membership Handlers ─────────────────────────────────
+1
apps/appview/src/lib/seed-roles.ts
··· 25 25 "space.atbb.permission.manageCategories", 26 26 "space.atbb.permission.manageRoles", 27 27 "space.atbb.permission.manageMembers", 28 + "space.atbb.permission.manageThemes", 28 29 "space.atbb.permission.moderatePosts", 29 30 "space.atbb.permission.banUsers", 30 31 "space.atbb.permission.pinTopics",
+779 -1
apps/appview/src/routes/__tests__/admin.test.ts
··· 2 2 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 3 3 import { Hono } from "hono"; 4 4 import type { Variables } from "../../types.js"; 5 - import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions } from "@atbb/db"; 5 + import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions, themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 6 6 import { eq } from "drizzle-orm"; 7 7 8 8 // Mock middleware at module level ··· 2442 2442 const data = await res.json() as any; 2443 2443 expect(data.limit).toBe(50); 2444 2444 expect(data.offset).toBe(0); 2445 + }); 2446 + }); 2447 + 2448 + describe("POST /api/admin/themes", () => { 2449 + it("creates theme and returns 201 with uri and cid", async () => { 2450 + const res = await app.request("/api/admin/themes", { 2451 + method: "POST", 2452 + headers: { "Content-Type": "application/json" }, 2453 + body: JSON.stringify({ 2454 + name: "Neobrutal Light", 2455 + colorScheme: "light", 2456 + tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 2457 + }), 2458 + }); 2459 + expect(res.status).toBe(201); 2460 + const body = await res.json(); 2461 + expect(body.uri).toBeDefined(); 2462 + expect(body.cid).toBeDefined(); 2463 + expect(mockPutRecord).toHaveBeenCalledOnce(); 2464 + }); 2465 + 2466 + it("includes cssOverrides and fontUrls when provided", async () => { 2467 + const res = await app.request("/api/admin/themes", { 2468 + method: "POST", 2469 + headers: { "Content-Type": "application/json" }, 2470 + body: JSON.stringify({ 2471 + name: "Custom Theme", 2472 + colorScheme: "dark", 2473 + tokens: { "color-bg": "#1a1a1a" }, 2474 + cssOverrides: ".card { border-radius: 4px; }", 2475 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"], 2476 + }), 2477 + }); 2478 + expect(res.status).toBe(201); 2479 + const call = mockPutRecord.mock.calls[0][0]; 2480 + expect(call.record.cssOverrides).toBe(".card { border-radius: 4px; }"); 2481 + expect(call.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 2482 + }); 2483 + 2484 + it("returns 400 when name is missing", async () => { 2485 + const res = await app.request("/api/admin/themes", { 2486 + method: "POST", 2487 + headers: { "Content-Type": "application/json" }, 2488 + body: JSON.stringify({ colorScheme: "light", tokens: {} }), 2489 + }); 2490 + expect(res.status).toBe(400); 2491 + const body = await res.json(); 2492 + expect(body.error).toMatch(/name/i); 2493 + }); 2494 + 2495 + it("returns 400 when name is empty string", async () => { 2496 + const res = await app.request("/api/admin/themes", { 2497 + method: "POST", 2498 + headers: { "Content-Type": "application/json" }, 2499 + body: JSON.stringify({ name: " ", colorScheme: "light", tokens: {} }), 2500 + }); 2501 + expect(res.status).toBe(400); 2502 + }); 2503 + 2504 + it("returns 400 when colorScheme is invalid", async () => { 2505 + const res = await app.request("/api/admin/themes", { 2506 + method: "POST", 2507 + headers: { "Content-Type": "application/json" }, 2508 + body: JSON.stringify({ name: "Test", colorScheme: "purple", tokens: {} }), 2509 + }); 2510 + expect(res.status).toBe(400); 2511 + const body = await res.json(); 2512 + expect(body.error).toMatch(/colorScheme/i); 2513 + }); 2514 + 2515 + it("returns 400 when colorScheme is missing", async () => { 2516 + const res = await app.request("/api/admin/themes", { 2517 + method: "POST", 2518 + headers: { "Content-Type": "application/json" }, 2519 + body: JSON.stringify({ name: "Test", tokens: {} }), 2520 + }); 2521 + expect(res.status).toBe(400); 2522 + }); 2523 + 2524 + it("returns 400 when tokens is missing", async () => { 2525 + const res = await app.request("/api/admin/themes", { 2526 + method: "POST", 2527 + headers: { "Content-Type": "application/json" }, 2528 + body: JSON.stringify({ name: "Test", colorScheme: "light" }), 2529 + }); 2530 + expect(res.status).toBe(400); 2531 + const body = await res.json(); 2532 + expect(body.error).toMatch(/tokens/i); 2533 + }); 2534 + 2535 + it("returns 400 when tokens is an array (not an object)", async () => { 2536 + const res = await app.request("/api/admin/themes", { 2537 + method: "POST", 2538 + headers: { "Content-Type": "application/json" }, 2539 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a", "b"] }), 2540 + }); 2541 + expect(res.status).toBe(400); 2542 + }); 2543 + 2544 + it("returns 400 when a token value is not a string", async () => { 2545 + const res = await app.request("/api/admin/themes", { 2546 + method: "POST", 2547 + headers: { "Content-Type": "application/json" }, 2548 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: { "color-bg": 123 } }), 2549 + }); 2550 + expect(res.status).toBe(400); 2551 + const body = await res.json(); 2552 + expect(body.error).toMatch(/tokens/i); 2553 + }); 2554 + 2555 + it("returns 400 when a fontUrl is not HTTPS", async () => { 2556 + const res = await app.request("/api/admin/themes", { 2557 + method: "POST", 2558 + headers: { "Content-Type": "application/json" }, 2559 + body: JSON.stringify({ 2560 + name: "Test", 2561 + colorScheme: "light", 2562 + tokens: {}, 2563 + fontUrls: ["http://example.com/font.css"], 2564 + }), 2565 + }); 2566 + expect(res.status).toBe(400); 2567 + const body = await res.json(); 2568 + expect(body.error).toMatch(/https/i); 2569 + }); 2570 + 2571 + it("returns 500 when ForumAgent is not configured", async () => { 2572 + ctx.forumAgent = null; 2573 + const res = await app.request("/api/admin/themes", { 2574 + method: "POST", 2575 + headers: { "Content-Type": "application/json" }, 2576 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 2577 + }); 2578 + expect(res.status).toBe(500); 2579 + const body = await res.json(); 2580 + expect(body.error).toContain("Forum agent not available"); 2581 + }); 2582 + 2583 + it("returns 503 when ForumAgent not authenticated", async () => { 2584 + const originalAgent = ctx.forumAgent; 2585 + ctx.forumAgent = { getAgent: () => null } as any; 2586 + const res = await app.request("/api/admin/themes", { 2587 + method: "POST", 2588 + headers: { "Content-Type": "application/json" }, 2589 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 2590 + }); 2591 + expect(res.status).toBe(503); 2592 + const body = await res.json(); 2593 + expect(body.error).toBe("Forum agent not authenticated. Please try again later."); 2594 + expect(mockPutRecord).not.toHaveBeenCalled(); 2595 + ctx.forumAgent = originalAgent; 2596 + }); 2597 + 2598 + it("returns 401 when not authenticated", async () => { 2599 + mockUser = null; 2600 + const res = await app.request("/api/admin/themes", { 2601 + method: "POST", 2602 + headers: { "Content-Type": "application/json" }, 2603 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 2604 + }); 2605 + expect(res.status).toBe(401); 2606 + expect(mockPutRecord).not.toHaveBeenCalled(); 2607 + }); 2608 + 2609 + it("returns 403 when user lacks manageThemes permission", async () => { 2610 + const { requirePermission } = await import("../../middleware/permissions.js"); 2611 + const mockRequirePermission = requirePermission as any; 2612 + mockRequirePermission.mockImplementation(() => async (c: any) => { 2613 + return c.json({ error: "Forbidden" }, 403); 2614 + }); 2615 + 2616 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 2617 + const res = await testApp.request("/api/admin/themes", { 2618 + method: "POST", 2619 + headers: { "Content-Type": "application/json" }, 2620 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 2621 + }); 2622 + 2623 + expect(res.status).toBe(403); 2624 + expect(mockPutRecord).not.toHaveBeenCalled(); 2625 + 2626 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 2627 + await next(); 2628 + }); 2629 + }); 2630 + 2631 + it("returns 503 when PDS write fails with a network error", async () => { 2632 + mockPutRecord.mockRejectedValueOnce(new Error("fetch failed")); 2633 + const res = await app.request("/api/admin/themes", { 2634 + method: "POST", 2635 + headers: { "Content-Type": "application/json" }, 2636 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 2637 + }); 2638 + expect(res.status).toBe(503); 2639 + }); 2640 + }); 2641 + 2642 + describe("PUT /api/admin/themes/:rkey", () => { 2643 + const TEST_RKEY = "3lblputtest1"; 2644 + const TEST_CREATED_AT = new Date("2026-01-01T00:00:00Z"); 2645 + 2646 + beforeEach(async () => { 2647 + await ctx.db.insert(themes).values({ 2648 + did: ctx.config.forumDid, 2649 + rkey: TEST_RKEY, 2650 + cid: "bafythemeput", 2651 + name: "Original Theme", 2652 + colorScheme: "light", 2653 + tokens: { "color-bg": "#ffffff", "color-text": "#000000" }, 2654 + cssOverrides: ".existing { color: red; }", 2655 + fontUrls: ["https://fonts.example.com/existing.css"], 2656 + createdAt: TEST_CREATED_AT, 2657 + indexedAt: new Date(), 2658 + }); 2659 + }); 2660 + 2661 + it("updates theme and returns 200 with uri and cid", async () => { 2662 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2663 + method: "PUT", 2664 + headers: { "Content-Type": "application/json" }, 2665 + body: JSON.stringify({ 2666 + name: "Updated Theme", 2667 + colorScheme: "dark", 2668 + tokens: { "color-bg": "#1a1a1a", "color-text": "#ffffff" }, 2669 + }), 2670 + }); 2671 + expect(res.status).toBe(200); 2672 + const body = await res.json(); 2673 + expect(body.uri).toBeDefined(); 2674 + expect(body.cid).toBeDefined(); 2675 + expect(mockPutRecord).toHaveBeenCalledOnce(); 2676 + const call = mockPutRecord.mock.calls[0][0]; 2677 + expect(call.record.name).toBe("Updated Theme"); 2678 + expect(call.record.colorScheme).toBe("dark"); 2679 + expect(call.rkey).toBe(TEST_RKEY); 2680 + }); 2681 + 2682 + it("preserves existing cssOverrides when not provided in request body", async () => { 2683 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2684 + method: "PUT", 2685 + headers: { "Content-Type": "application/json" }, 2686 + body: JSON.stringify({ 2687 + name: "Updated Theme", 2688 + colorScheme: "light", 2689 + tokens: { "color-bg": "#f0f0f0" }, 2690 + }), 2691 + }); 2692 + expect(res.status).toBe(200); 2693 + const call = mockPutRecord.mock.calls[0][0]; 2694 + expect(call.record.cssOverrides).toBe(".existing { color: red; }"); 2695 + }); 2696 + 2697 + it("preserves existing fontUrls when not provided in request body", async () => { 2698 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2699 + method: "PUT", 2700 + headers: { "Content-Type": "application/json" }, 2701 + body: JSON.stringify({ 2702 + name: "Updated Theme", 2703 + colorScheme: "light", 2704 + tokens: { "color-bg": "#f0f0f0" }, 2705 + }), 2706 + }); 2707 + expect(res.status).toBe(200); 2708 + const call = mockPutRecord.mock.calls[0][0]; 2709 + expect(call.record.fontUrls).toEqual(["https://fonts.example.com/existing.css"]); 2710 + }); 2711 + 2712 + it("preserves original createdAt in the PDS record", async () => { 2713 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2714 + method: "PUT", 2715 + headers: { "Content-Type": "application/json" }, 2716 + body: JSON.stringify({ 2717 + name: "Updated Theme", 2718 + colorScheme: "light", 2719 + tokens: { "color-bg": "#f0f0f0" }, 2720 + }), 2721 + }); 2722 + expect(res.status).toBe(200); 2723 + const call = mockPutRecord.mock.calls[0][0]; 2724 + expect(call.record.createdAt).toBe("2026-01-01T00:00:00.000Z"); 2725 + }); 2726 + 2727 + it("returns 404 for unknown rkey", async () => { 2728 + const res = await app.request("/api/admin/themes/nonexistentkey", { 2729 + method: "PUT", 2730 + headers: { "Content-Type": "application/json" }, 2731 + body: JSON.stringify({ 2732 + name: "Updated Theme", 2733 + colorScheme: "light", 2734 + tokens: { "color-bg": "#f0f0f0" }, 2735 + }), 2736 + }); 2737 + expect(res.status).toBe(404); 2738 + const body = await res.json(); 2739 + expect(body.error).toMatch(/not found/i); 2740 + }); 2741 + 2742 + it("returns 400 when name is missing", async () => { 2743 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2744 + method: "PUT", 2745 + headers: { "Content-Type": "application/json" }, 2746 + body: JSON.stringify({ colorScheme: "light", tokens: {} }), 2747 + }); 2748 + expect(res.status).toBe(400); 2749 + const body = await res.json(); 2750 + expect(body.error).toMatch(/name/i); 2751 + }); 2752 + 2753 + it("returns 400 when colorScheme is invalid", async () => { 2754 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2755 + method: "PUT", 2756 + headers: { "Content-Type": "application/json" }, 2757 + body: JSON.stringify({ name: "Test", colorScheme: "sepia", tokens: {} }), 2758 + }); 2759 + expect(res.status).toBe(400); 2760 + const body = await res.json(); 2761 + expect(body.error).toMatch(/colorScheme/i); 2762 + }); 2763 + 2764 + it("returns 400 when tokens is an array", async () => { 2765 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2766 + method: "PUT", 2767 + headers: { "Content-Type": "application/json" }, 2768 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a", "b"] }), 2769 + }); 2770 + expect(res.status).toBe(400); 2771 + const body = await res.json(); 2772 + expect(body.error).toMatch(/tokens/i); 2773 + }); 2774 + 2775 + it("returns 401 when not authenticated", async () => { 2776 + mockUser = null; 2777 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2778 + method: "PUT", 2779 + headers: { "Content-Type": "application/json" }, 2780 + body: JSON.stringify({ 2781 + name: "Updated Theme", 2782 + colorScheme: "light", 2783 + tokens: { "color-bg": "#f0f0f0" }, 2784 + }), 2785 + }); 2786 + expect(res.status).toBe(401); 2787 + expect(mockPutRecord).not.toHaveBeenCalled(); 2788 + }); 2789 + 2790 + it("returns 403 when user lacks manageThemes permission", async () => { 2791 + const { requirePermission } = await import("../../middleware/permissions.js"); 2792 + const mockRequirePermission = requirePermission as any; 2793 + mockRequirePermission.mockImplementation(() => async (c: any) => { 2794 + return c.json({ error: "Forbidden" }, 403); 2795 + }); 2796 + 2797 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 2798 + const res = await testApp.request(`/api/admin/themes/${TEST_RKEY}`, { 2799 + method: "PUT", 2800 + headers: { "Content-Type": "application/json" }, 2801 + body: JSON.stringify({ 2802 + name: "Updated Theme", 2803 + colorScheme: "light", 2804 + tokens: { "color-bg": "#f0f0f0" }, 2805 + }), 2806 + }); 2807 + 2808 + expect(res.status).toBe(403); 2809 + expect(mockPutRecord).not.toHaveBeenCalled(); 2810 + 2811 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 2812 + await next(); 2813 + }); 2814 + }); 2815 + 2816 + it("returns 503 when ForumAgent not authenticated", async () => { 2817 + const originalAgent = ctx.forumAgent; 2818 + ctx.forumAgent = { getAgent: () => null } as any; 2819 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2820 + method: "PUT", 2821 + headers: { "Content-Type": "application/json" }, 2822 + body: JSON.stringify({ name: "Updated", colorScheme: "light", tokens: {} }), 2823 + }); 2824 + expect(res.status).toBe(503); 2825 + const body = await res.json(); 2826 + expect(body.error).toBe("Forum agent not authenticated. Please try again later."); 2827 + expect(mockPutRecord).not.toHaveBeenCalled(); 2828 + ctx.forumAgent = originalAgent; 2829 + }); 2830 + 2831 + it("returns 503 when PDS write fails with a network error", async () => { 2832 + mockPutRecord.mockRejectedValueOnce(new Error("fetch failed")); 2833 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2834 + method: "PUT", 2835 + headers: { "Content-Type": "application/json" }, 2836 + body: JSON.stringify({ 2837 + name: "Updated Theme", 2838 + colorScheme: "light", 2839 + tokens: { "color-bg": "#f0f0f0" }, 2840 + }), 2841 + }); 2842 + expect(res.status).toBe(503); 2843 + }); 2844 + }); 2845 + 2846 + describe("DELETE /api/admin/themes/:rkey", () => { 2847 + const themeRkey = "3lbldeltest1"; 2848 + 2849 + beforeEach(async () => { 2850 + await ctx.db.insert(themes).values({ 2851 + did: ctx.config.forumDid, 2852 + rkey: themeRkey, 2853 + cid: "bafydeltest", 2854 + name: "Theme To Delete", 2855 + colorScheme: "light", 2856 + tokens: { "color-bg": "#ffffff" }, 2857 + createdAt: new Date(), 2858 + indexedAt: new Date(), 2859 + }); 2860 + }); 2861 + 2862 + it("deletes theme and returns 200 with success: true", async () => { 2863 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2864 + method: "DELETE", 2865 + }); 2866 + expect(res.status).toBe(200); 2867 + const body = await res.json(); 2868 + expect(body.success).toBe(true); 2869 + expect(mockDeleteRecord).toHaveBeenCalledWith({ 2870 + repo: ctx.config.forumDid, 2871 + collection: "space.atbb.forum.theme", 2872 + rkey: themeRkey, 2873 + }); 2874 + }); 2875 + 2876 + it("returns 404 for unknown rkey", async () => { 2877 + const res = await app.request("/api/admin/themes/doesnotexist", { 2878 + method: "DELETE", 2879 + }); 2880 + expect(res.status).toBe(404); 2881 + }); 2882 + 2883 + it("returns 409 when theme is the defaultLightTheme in policy", async () => { 2884 + await ctx.db.insert(themePolicies).values({ 2885 + did: ctx.config.forumDid, 2886 + rkey: "self", 2887 + cid: "bafypolicydel", 2888 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 2889 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 2890 + allowUserChoice: true, 2891 + indexedAt: new Date(), 2892 + }); 2893 + 2894 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2895 + method: "DELETE", 2896 + }); 2897 + expect(res.status).toBe(409); 2898 + const body = await res.json(); 2899 + expect(body.error).toMatch(/default/i); 2900 + }); 2901 + 2902 + it("returns 409 when theme is the defaultDarkTheme in policy", async () => { 2903 + await ctx.db.insert(themePolicies).values({ 2904 + did: ctx.config.forumDid, 2905 + rkey: "self", 2906 + cid: "bafypolicydel2", 2907 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 2908 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 2909 + allowUserChoice: true, 2910 + indexedAt: new Date(), 2911 + }); 2912 + 2913 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2914 + method: "DELETE", 2915 + }); 2916 + expect(res.status).toBe(409); 2917 + const body = await res.json(); 2918 + expect(body.error).toMatch(/default/i); 2919 + }); 2920 + 2921 + it("deletes successfully when theme exists in policy availableThemes but not as a default", async () => { 2922 + const [policy] = await ctx.db.insert(themePolicies).values({ 2923 + did: ctx.config.forumDid, 2924 + rkey: "self", 2925 + cid: "bafypolicyavail", 2926 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 2927 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 2928 + allowUserChoice: true, 2929 + indexedAt: new Date(), 2930 + }).returning(); 2931 + await ctx.db.insert(themePolicyAvailableThemes).values({ 2932 + policyId: policy.id, 2933 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 2934 + themeCid: "bafydeltest", 2935 + }); 2936 + 2937 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2938 + method: "DELETE", 2939 + }); 2940 + expect(res.status).toBe(200); 2941 + }); 2942 + 2943 + it("returns 401 when not authenticated", async () => { 2944 + mockUser = null; 2945 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2946 + method: "DELETE", 2947 + }); 2948 + expect(res.status).toBe(401); 2949 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 2950 + }); 2951 + 2952 + it("returns 403 when user lacks manageThemes permission", async () => { 2953 + const { requirePermission } = await import("../../middleware/permissions.js"); 2954 + const mockRequirePermission = requirePermission as any; 2955 + mockRequirePermission.mockImplementation(() => async (c: any) => { 2956 + return c.json({ error: "Forbidden" }, 403); 2957 + }); 2958 + 2959 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 2960 + const res = await testApp.request(`/api/admin/themes/${themeRkey}`, { 2961 + method: "DELETE", 2962 + }); 2963 + 2964 + expect(res.status).toBe(403); 2965 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 2966 + 2967 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 2968 + await next(); 2969 + }); 2970 + }); 2971 + 2972 + it("returns 503 when ForumAgent not authenticated", async () => { 2973 + const originalAgent = ctx.forumAgent; 2974 + ctx.forumAgent = { getAgent: () => null } as any; 2975 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2976 + method: "DELETE", 2977 + }); 2978 + expect(res.status).toBe(503); 2979 + const body = await res.json(); 2980 + expect(body.error).toBe("Forum agent not authenticated. Please try again later."); 2981 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 2982 + ctx.forumAgent = originalAgent; 2983 + }); 2984 + 2985 + it("returns 503 when PDS delete fails with a network error", async () => { 2986 + mockDeleteRecord.mockRejectedValueOnce(new Error("fetch failed")); 2987 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2988 + method: "DELETE", 2989 + }); 2990 + expect(res.status).toBe(503); 2991 + }); 2992 + }); 2993 + 2994 + describe("PUT /api/admin/theme-policy", () => { 2995 + const lightUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbllight1`; 2996 + const darkUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbldark11`; 2997 + 2998 + const validBody = { 2999 + availableThemes: [ 3000 + { uri: lightUri, cid: "bafylight" }, 3001 + { uri: darkUri, cid: "bafydark" }, 3002 + ], 3003 + defaultLightThemeUri: lightUri, 3004 + defaultDarkThemeUri: darkUri, 3005 + allowUserChoice: true, 3006 + }; 3007 + 3008 + it("creates policy (upsert) and returns 200 with uri and cid", async () => { 3009 + const res = await app.request("/api/admin/theme-policy", { 3010 + method: "PUT", 3011 + headers: { "Content-Type": "application/json" }, 3012 + body: JSON.stringify(validBody), 3013 + }); 3014 + expect(res.status).toBe(200); 3015 + const body = await res.json(); 3016 + expect(body.uri).toBeDefined(); 3017 + expect(body.cid).toBeDefined(); 3018 + expect(mockPutRecord).toHaveBeenCalledOnce(); 3019 + }); 3020 + 3021 + it("writes PDS record with themeRef wrapper structure", async () => { 3022 + await app.request("/api/admin/theme-policy", { 3023 + method: "PUT", 3024 + headers: { "Content-Type": "application/json" }, 3025 + body: JSON.stringify(validBody), 3026 + }); 3027 + const call = mockPutRecord.mock.calls[0][0]; 3028 + expect(call.record.$type).toBe("space.atbb.forum.themePolicy"); 3029 + expect(call.rkey).toBe("self"); 3030 + // availableThemes wrapped in { theme: { uri, cid } } 3031 + expect(call.record.availableThemes[0]).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 3032 + expect(call.record.defaultLightTheme).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 3033 + expect(call.record.defaultDarkTheme).toEqual({ theme: { uri: darkUri, cid: "bafydark" } }); 3034 + expect(call.record.allowUserChoice).toBe(true); 3035 + expect(typeof call.record.updatedAt).toBe("string"); 3036 + expect(call.collection).toBe("space.atbb.forum.themePolicy"); 3037 + expect(call.repo).toBe(ctx.config.forumDid); 3038 + }); 3039 + 3040 + it("overwrites existing policy (upsert) and returns 200 with uri and cid", async () => { 3041 + await ctx.db.insert(themePolicies).values({ 3042 + did: ctx.config.forumDid, 3043 + rkey: "self", 3044 + cid: "bafyexisting", 3045 + defaultLightThemeUri: lightUri, 3046 + defaultDarkThemeUri: darkUri, 3047 + allowUserChoice: false, 3048 + indexedAt: new Date(), 3049 + }); 3050 + 3051 + const res = await app.request("/api/admin/theme-policy", { 3052 + method: "PUT", 3053 + headers: { "Content-Type": "application/json" }, 3054 + body: JSON.stringify(validBody), 3055 + }); 3056 + expect(res.status).toBe(200); 3057 + const body = await res.json(); 3058 + expect(body.uri).toBeDefined(); 3059 + expect(body.cid).toBeDefined(); 3060 + expect(mockPutRecord).toHaveBeenCalledOnce(); 3061 + }); 3062 + 3063 + it("defaults allowUserChoice to true when not provided", async () => { 3064 + const { allowUserChoice: _, ...bodyWithout } = validBody; 3065 + await app.request("/api/admin/theme-policy", { 3066 + method: "PUT", 3067 + headers: { "Content-Type": "application/json" }, 3068 + body: JSON.stringify(bodyWithout), 3069 + }); 3070 + const call = mockPutRecord.mock.calls[0][0]; 3071 + expect(call.record.allowUserChoice).toBe(true); 3072 + }); 3073 + 3074 + it("returns 400 when availableThemes is missing", async () => { 3075 + const { availableThemes: _, ...bodyWithout } = validBody; 3076 + const res = await app.request("/api/admin/theme-policy", { 3077 + method: "PUT", 3078 + headers: { "Content-Type": "application/json" }, 3079 + body: JSON.stringify(bodyWithout), 3080 + }); 3081 + expect(res.status).toBe(400); 3082 + const body = await res.json(); 3083 + expect(body.error).toMatch(/availableThemes/i); 3084 + }); 3085 + 3086 + it("returns 400 when availableThemes is empty array", async () => { 3087 + const res = await app.request("/api/admin/theme-policy", { 3088 + method: "PUT", 3089 + headers: { "Content-Type": "application/json" }, 3090 + body: JSON.stringify({ ...validBody, availableThemes: [] }), 3091 + }); 3092 + expect(res.status).toBe(400); 3093 + const body = await res.json(); 3094 + expect(body.error).toMatch(/availableThemes/i); 3095 + }); 3096 + 3097 + it("returns 400 when availableThemes item is missing cid", async () => { 3098 + const res = await app.request("/api/admin/theme-policy", { 3099 + method: "PUT", 3100 + headers: { "Content-Type": "application/json" }, 3101 + body: JSON.stringify({ 3102 + ...validBody, 3103 + availableThemes: [{ uri: lightUri }], // missing cid 3104 + defaultLightThemeUri: lightUri, 3105 + defaultDarkThemeUri: lightUri, 3106 + }), 3107 + }); 3108 + expect(res.status).toBe(400); 3109 + const body = await res.json(); 3110 + expect(body.error).toMatch(/uri.*cid|cid.*uri|uri and cid/i); 3111 + }); 3112 + 3113 + it("returns 400 when defaultLightThemeUri is not in availableThemes", async () => { 3114 + const res = await app.request("/api/admin/theme-policy", { 3115 + method: "PUT", 3116 + headers: { "Content-Type": "application/json" }, 3117 + body: JSON.stringify({ 3118 + ...validBody, 3119 + defaultLightThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist", 3120 + }), 3121 + }); 3122 + expect(res.status).toBe(400); 3123 + const body = await res.json(); 3124 + expect(body.error).toMatch(/defaultLightThemeUri/i); 3125 + }); 3126 + 3127 + it("returns 400 when defaultDarkThemeUri is not in availableThemes", async () => { 3128 + const res = await app.request("/api/admin/theme-policy", { 3129 + method: "PUT", 3130 + headers: { "Content-Type": "application/json" }, 3131 + body: JSON.stringify({ 3132 + ...validBody, 3133 + defaultDarkThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist", 3134 + }), 3135 + }); 3136 + expect(res.status).toBe(400); 3137 + const body = await res.json(); 3138 + expect(body.error).toMatch(/defaultDarkThemeUri/i); 3139 + }); 3140 + 3141 + it("returns 400 when defaultLightThemeUri is missing", async () => { 3142 + const { defaultLightThemeUri: _, ...bodyWithout } = validBody; 3143 + const res = await app.request("/api/admin/theme-policy", { 3144 + method: "PUT", 3145 + headers: { "Content-Type": "application/json" }, 3146 + body: JSON.stringify(bodyWithout), 3147 + }); 3148 + expect(res.status).toBe(400); 3149 + const body = await res.json(); 3150 + expect(body.error).toMatch(/defaultLightThemeUri/i); 3151 + }); 3152 + 3153 + it("returns 400 when defaultDarkThemeUri is missing", async () => { 3154 + const { defaultDarkThemeUri: _, ...bodyWithout } = validBody; 3155 + const res = await app.request("/api/admin/theme-policy", { 3156 + method: "PUT", 3157 + headers: { "Content-Type": "application/json" }, 3158 + body: JSON.stringify(bodyWithout), 3159 + }); 3160 + expect(res.status).toBe(400); 3161 + const body = await res.json(); 3162 + expect(body.error).toMatch(/defaultDarkThemeUri/i); 3163 + }); 3164 + 3165 + it("returns 401 when not authenticated", async () => { 3166 + mockUser = null; 3167 + const res = await app.request("/api/admin/theme-policy", { 3168 + method: "PUT", 3169 + headers: { "Content-Type": "application/json" }, 3170 + body: JSON.stringify(validBody), 3171 + }); 3172 + expect(res.status).toBe(401); 3173 + expect(mockPutRecord).not.toHaveBeenCalled(); 3174 + }); 3175 + 3176 + it("returns 403 when user lacks manageThemes permission", async () => { 3177 + const { requirePermission } = await import("../../middleware/permissions.js"); 3178 + const mockRequirePermission = requirePermission as any; 3179 + mockRequirePermission.mockImplementation(() => async (c: any) => { 3180 + return c.json({ error: "Forbidden" }, 403); 3181 + }); 3182 + 3183 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 3184 + const res = await testApp.request("/api/admin/theme-policy", { 3185 + method: "PUT", 3186 + headers: { "Content-Type": "application/json" }, 3187 + body: JSON.stringify(validBody), 3188 + }); 3189 + 3190 + expect(res.status).toBe(403); 3191 + expect(mockPutRecord).not.toHaveBeenCalled(); 3192 + 3193 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 3194 + await next(); 3195 + }); 3196 + }); 3197 + 3198 + it("returns 500 when ForumAgent is not configured", async () => { 3199 + ctx.forumAgent = null; 3200 + const res = await app.request("/api/admin/theme-policy", { 3201 + method: "PUT", 3202 + headers: { "Content-Type": "application/json" }, 3203 + body: JSON.stringify(validBody), 3204 + }); 3205 + expect(res.status).toBe(500); 3206 + const body = await res.json(); 3207 + expect(body.error).toContain("Forum agent not available"); 3208 + }); 3209 + 3210 + it("returns 503 when ForumAgent not authenticated", async () => { 3211 + const originalAgent = ctx.forumAgent; 3212 + ctx.forumAgent = { getAgent: () => null } as any; 3213 + const res = await app.request("/api/admin/theme-policy", { 3214 + method: "PUT", 3215 + headers: { "Content-Type": "application/json" }, 3216 + body: JSON.stringify(validBody), 3217 + }); 3218 + expect(res.status).toBe(503); 3219 + const body = await res.json(); 3220 + expect(body.error).toBe("Forum agent not authenticated. Please try again later."); 3221 + expect(mockPutRecord).not.toHaveBeenCalled(); 3222 + ctx.forumAgent = originalAgent; 2445 3223 }); 2446 3224 }); 2447 3225
+292
apps/appview/src/routes/__tests__/themes.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 4 + import { createThemesRoutes, createThemePolicyRoutes } from "../themes.js"; 5 + import { themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 6 + 7 + // ── GET /api/themes ────────────────────────────────────── 8 + 9 + describe("GET /api/themes", () => { 10 + let ctx: TestContext; 11 + let app: Hono; 12 + 13 + beforeEach(async () => { 14 + ctx = await createTestContext(); 15 + app = new Hono() 16 + .route("/themes", createThemesRoutes(ctx)) 17 + .route("/theme-policy", createThemePolicyRoutes(ctx)); 18 + }); 19 + 20 + afterEach(async () => { 21 + await ctx.cleanup(); 22 + }); 23 + 24 + it("returns empty array when no policy exists", async () => { 25 + const res = await app.request("/themes"); 26 + expect(res.status).toBe(200); 27 + const body = await res.json(); 28 + expect(body).toHaveProperty("themes"); 29 + expect(body.themes).toEqual([]); 30 + }); 31 + 32 + it("returns only themes listed in availableThemes (not all themes in DB)", async () => { 33 + await ctx.db.insert(themes).values([ 34 + { 35 + did: ctx.config.forumDid, 36 + rkey: "3lbltheme1aa", 37 + cid: "bafytheme1", 38 + name: "Neobrutal Light", 39 + colorScheme: "light", 40 + tokens: { "color-bg": "#f5f0e8" }, 41 + createdAt: new Date(), 42 + indexedAt: new Date(), 43 + }, 44 + { 45 + did: ctx.config.forumDid, 46 + rkey: "3lbltheme2bb", 47 + cid: "bafytheme2", 48 + name: "Neobrutal Dark", 49 + colorScheme: "dark", 50 + tokens: { "color-bg": "#1a1a1a" }, 51 + createdAt: new Date(), 52 + indexedAt: new Date(), 53 + }, 54 + ]); 55 + 56 + const [policy] = await ctx.db 57 + .insert(themePolicies) 58 + .values({ 59 + did: ctx.config.forumDid, 60 + rkey: "self", 61 + cid: "bafypolicy1", 62 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme1aa`, 63 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme2bb`, 64 + allowUserChoice: true, 65 + indexedAt: new Date(), 66 + }) 67 + .returning(); 68 + 69 + // Only expose theme1 (light) — theme2 (dark) stays hidden 70 + await ctx.db.insert(themePolicyAvailableThemes).values({ 71 + policyId: policy.id, 72 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme1aa`, 73 + themeCid: "bafytheme1", 74 + }); 75 + 76 + const res = await app.request("/themes"); 77 + expect(res.status).toBe(200); 78 + const body = await res.json(); 79 + 80 + expect(body.themes).toHaveLength(1); 81 + expect(body.themes[0].name).toBe("Neobrutal Light"); 82 + // theme2 is in DB but NOT in availableThemes — must not appear 83 + const names = body.themes.map((t: any) => t.name); 84 + expect(names).not.toContain("Neobrutal Dark"); 85 + }); 86 + 87 + it("returns summary shape (id, uri, name, colorScheme, indexedAt — no tokens or cssOverrides)", async () => { 88 + await ctx.db.insert(themes).values({ 89 + did: ctx.config.forumDid, 90 + rkey: "3lbltheme3cc", 91 + cid: "bafytheme3", 92 + name: "Clean Light", 93 + colorScheme: "light", 94 + tokens: { "color-bg": "#ffffff" }, 95 + cssOverrides: ".card { border-radius: 4px; }", 96 + createdAt: new Date(), 97 + indexedAt: new Date(), 98 + }); 99 + 100 + const [policy] = await ctx.db 101 + .insert(themePolicies) 102 + .values({ 103 + did: ctx.config.forumDid, 104 + rkey: "self", 105 + cid: "bafypolicy2", 106 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 107 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 108 + allowUserChoice: true, 109 + indexedAt: new Date(), 110 + }) 111 + .returning(); 112 + 113 + await ctx.db.insert(themePolicyAvailableThemes).values({ 114 + policyId: policy.id, 115 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 116 + themeCid: "bafytheme3", 117 + }); 118 + 119 + const res = await app.request("/themes"); 120 + const body = await res.json(); 121 + const theme = body.themes[0]; 122 + 123 + expect(theme).toHaveProperty("id"); 124 + expect(theme).toHaveProperty("uri"); 125 + expect(theme.uri).toContain("/space.atbb.forum.theme/3lbltheme3cc"); 126 + expect(theme).toHaveProperty("name"); 127 + expect(theme).toHaveProperty("colorScheme"); 128 + expect(theme).toHaveProperty("indexedAt"); 129 + // List endpoint must NOT return full token set 130 + expect(theme).not.toHaveProperty("tokens"); 131 + expect(theme).not.toHaveProperty("cssOverrides"); 132 + expect(theme).not.toHaveProperty("fontUrls"); 133 + }); 134 + 135 + it("returns 503 on database error", async () => { 136 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 137 + throw new Error("Database connection lost"); 138 + }); 139 + 140 + const res = await app.request("/themes"); 141 + expect(res.status).toBe(503); 142 + }); 143 + }); 144 + 145 + // ── GET /api/themes/:rkey ──────────────────────────────── 146 + 147 + describe("GET /api/themes/:rkey", () => { 148 + let ctx: TestContext; 149 + let app: Hono; 150 + 151 + beforeEach(async () => { 152 + ctx = await createTestContext(); 153 + app = new Hono().route("/themes", createThemesRoutes(ctx)); 154 + }); 155 + 156 + afterEach(async () => { 157 + await ctx.cleanup(); 158 + }); 159 + 160 + it("returns 404 for unknown rkey", async () => { 161 + const res = await app.request("/themes/nonexistent"); 162 + expect(res.status).toBe(404); 163 + const body = await res.json(); 164 + expect(body.error).toBeDefined(); 165 + }); 166 + 167 + it("returns full theme data including tokens, cssOverrides, and fontUrls", async () => { 168 + await ctx.db.insert(themes).values({ 169 + did: ctx.config.forumDid, 170 + rkey: "3lblfulltest", 171 + cid: "bafyfull", 172 + name: "Neobrutal Light", 173 + colorScheme: "light", 174 + tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 175 + cssOverrides: ".btn { font-weight: 700; }", 176 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"], 177 + createdAt: new Date(), 178 + indexedAt: new Date(), 179 + }); 180 + 181 + const res = await app.request("/themes/3lblfulltest"); 182 + expect(res.status).toBe(200); 183 + const body = await res.json(); 184 + 185 + expect(body.name).toBe("Neobrutal Light"); 186 + expect(body.colorScheme).toBe("light"); 187 + expect(body.tokens).toEqual({ "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }); 188 + expect(body.cssOverrides).toBe(".btn { font-weight: 700; }"); 189 + expect(body.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 190 + expect(body.uri).toContain("/space.atbb.forum.theme/3lblfulltest"); 191 + expect(body.indexedAt).toBeDefined(); 192 + }); 193 + 194 + it("returns null for optional fields when not set", async () => { 195 + await ctx.db.insert(themes).values({ 196 + did: ctx.config.forumDid, 197 + rkey: "3lblminimal", 198 + cid: "bafymin", 199 + name: "Minimal", 200 + colorScheme: "light", 201 + tokens: { "color-bg": "#fff" }, 202 + createdAt: new Date(), 203 + indexedAt: new Date(), 204 + }); 205 + 206 + const res = await app.request("/themes/3lblminimal"); 207 + expect(res.status).toBe(200); 208 + const body = await res.json(); 209 + expect(body.cssOverrides).toBeNull(); 210 + expect(body.fontUrls).toBeNull(); 211 + }); 212 + 213 + it("returns 503 on database error", async () => { 214 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 215 + throw new Error("Database connection lost"); 216 + }); 217 + 218 + const res = await app.request("/themes/any-rkey"); 219 + expect(res.status).toBe(503); 220 + }); 221 + }); 222 + 223 + // ── GET /api/theme-policy ──────────────────────────────── 224 + 225 + describe("GET /api/theme-policy", () => { 226 + let ctx: TestContext; 227 + let app: Hono; 228 + 229 + beforeEach(async () => { 230 + ctx = await createTestContext(); 231 + app = new Hono().route("/theme-policy", createThemePolicyRoutes(ctx)); 232 + }); 233 + 234 + afterEach(async () => { 235 + await ctx.cleanup(); 236 + }); 237 + 238 + it("returns 404 when no policy exists", async () => { 239 + const res = await app.request("/theme-policy"); 240 + expect(res.status).toBe(404); 241 + const body = await res.json(); 242 + expect(body.error).toBeDefined(); 243 + }); 244 + 245 + it("returns policy with correct fields", async () => { 246 + const [policy] = await ctx.db 247 + .insert(themePolicies) 248 + .values({ 249 + did: ctx.config.forumDid, 250 + rkey: "self", 251 + cid: "bafypol", 252 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbllight`, 253 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbldark`, 254 + allowUserChoice: false, 255 + indexedAt: new Date(), 256 + }) 257 + .returning(); 258 + 259 + await ctx.db.insert(themePolicyAvailableThemes).values([ 260 + { 261 + policyId: policy.id, 262 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbllight`, 263 + themeCid: "bafylight", 264 + }, 265 + { 266 + policyId: policy.id, 267 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbldark`, 268 + themeCid: "bafydark", 269 + }, 270 + ]); 271 + 272 + const res = await app.request("/theme-policy"); 273 + expect(res.status).toBe(200); 274 + const body = await res.json(); 275 + 276 + expect(body.defaultLightThemeUri).toContain("3lbllight"); 277 + expect(body.defaultDarkThemeUri).toContain("3lbldark"); 278 + expect(body.allowUserChoice).toBe(false); 279 + expect(body.availableThemes).toHaveLength(2); 280 + expect(body.availableThemes[0]).toHaveProperty("uri"); 281 + expect(body.availableThemes[0]).toHaveProperty("cid"); 282 + }); 283 + 284 + it("returns 503 on database error", async () => { 285 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 286 + throw new Error("Database connection lost"); 287 + }); 288 + 289 + const res = await app.request("/theme-policy"); 290 + expect(res.status).toBe(503); 291 + }); 292 + });
+350 -2
apps/appview/src/routes/admin.ts
··· 3 3 import type { Variables } from "../types.js"; 4 4 import { requireAuth } from "../middleware/auth.js"; 5 5 import { requirePermission, requireAnyPermission, getUserRole } from "../middleware/permissions.js"; 6 - import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions } from "@atbb/db"; 7 - import { eq, and, sql, asc, desc, count, inArray } from "drizzle-orm"; 6 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions, themes, themePolicies } from "@atbb/db"; 7 + import { eq, and, sql, asc, desc, count, inArray, or } from "drizzle-orm"; 8 8 import { isProgrammingError } from "../lib/errors.js"; 9 9 import { BackfillStatus } from "../lib/backfill-manager.js"; 10 10 import { CursorManager } from "../lib/cursor-manager.js"; ··· 975 975 operation: "DELETE /api/admin/boards/:id", 976 976 logger: ctx.logger, 977 977 id: idParam, 978 + }); 979 + } 980 + } 981 + ); 982 + 983 + /** 984 + * POST /api/admin/themes 985 + * 986 + * Create a new theme record on Forum DID's PDS. 987 + * Writes space.atbb.forum.theme with a fresh TID rkey. 988 + * The firehose indexer creates the DB row asynchronously. 989 + */ 990 + app.post( 991 + "/themes", 992 + requireAuth(ctx), 993 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 994 + async (c) => { 995 + const { body, error: parseError } = await safeParseJsonBody(c); 996 + if (parseError) return parseError; 997 + 998 + const { name, colorScheme, tokens, cssOverrides, fontUrls } = body; 999 + 1000 + if (typeof name !== "string" || name.trim().length === 0) { 1001 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 1002 + } 1003 + if (typeof colorScheme !== "string" || (colorScheme !== "light" && colorScheme !== "dark")) { 1004 + return c.json({ error: 'colorScheme is required and must be "light" or "dark"' }, 400); 1005 + } 1006 + if (tokens === null || tokens === undefined || typeof tokens !== "object" || Array.isArray(tokens)) { 1007 + return c.json({ error: "tokens is required and must be a plain object" }, 400); 1008 + } 1009 + for (const [key, val] of Object.entries(tokens as Record<string, unknown>)) { 1010 + if (typeof val !== "string") { 1011 + return c.json({ error: `tokens["${key}"] must be a string` }, 400); 1012 + } 1013 + } 1014 + if (cssOverrides !== undefined && typeof cssOverrides !== "string") { 1015 + return c.json({ error: "cssOverrides must be a string" }, 400); 1016 + } 1017 + if (fontUrls !== undefined) { 1018 + if (!Array.isArray(fontUrls)) { 1019 + return c.json({ error: "fontUrls must be an array of strings" }, 400); 1020 + } 1021 + for (const url of fontUrls as unknown[]) { 1022 + if (typeof url !== "string" || !url.startsWith("https://")) { 1023 + return c.json({ error: "fontUrls must contain only HTTPS URLs" }, 400); 1024 + } 1025 + } 1026 + } 1027 + 1028 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/themes"); 1029 + if (agentError) return agentError; 1030 + 1031 + const rkey = TID.nextStr(); 1032 + const now = new Date().toISOString(); 1033 + 1034 + try { 1035 + const result = await agent.com.atproto.repo.putRecord({ 1036 + repo: ctx.config.forumDid, 1037 + collection: "space.atbb.forum.theme", 1038 + rkey, 1039 + record: { 1040 + $type: "space.atbb.forum.theme", 1041 + name: name.trim(), 1042 + colorScheme, 1043 + tokens, 1044 + ...(typeof cssOverrides === "string" && { cssOverrides }), 1045 + ...(Array.isArray(fontUrls) && { fontUrls }), 1046 + createdAt: now, 1047 + }, 1048 + }); 1049 + 1050 + return c.json({ uri: result.data.uri, cid: result.data.cid }, 201); 1051 + } catch (error) { 1052 + return handleRouteError(c, error, "Failed to create theme", { 1053 + operation: "POST /api/admin/themes", 1054 + logger: ctx.logger, 1055 + }); 1056 + } 1057 + } 1058 + ); 1059 + 1060 + /** 1061 + * PUT /api/admin/themes/:rkey 1062 + * 1063 + * Update an existing theme. Fetches the existing row from DB to preserve 1064 + * createdAt and fall back optional fields not in the request body. 1065 + * The firehose indexer updates the DB row asynchronously. 1066 + */ 1067 + app.put( 1068 + "/themes/:rkey", 1069 + requireAuth(ctx), 1070 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 1071 + async (c) => { 1072 + const themeRkey = c.req.param("rkey").trim(); 1073 + 1074 + const { body, error: parseError } = await safeParseJsonBody(c); 1075 + if (parseError) return parseError; 1076 + 1077 + const { name, colorScheme, tokens, cssOverrides, fontUrls } = body; 1078 + 1079 + if (typeof name !== "string" || name.trim().length === 0) { 1080 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 1081 + } 1082 + if (typeof colorScheme !== "string" || (colorScheme !== "light" && colorScheme !== "dark")) { 1083 + return c.json({ error: 'colorScheme is required and must be "light" or "dark"' }, 400); 1084 + } 1085 + if (tokens === null || tokens === undefined || typeof tokens !== "object" || Array.isArray(tokens)) { 1086 + return c.json({ error: "tokens is required and must be a plain object" }, 400); 1087 + } 1088 + for (const [key, val] of Object.entries(tokens as Record<string, unknown>)) { 1089 + if (typeof val !== "string") { 1090 + return c.json({ error: `tokens["${key}"] must be a string` }, 400); 1091 + } 1092 + } 1093 + if (cssOverrides !== undefined && typeof cssOverrides !== "string") { 1094 + return c.json({ error: "cssOverrides must be a string" }, 400); 1095 + } 1096 + if (fontUrls !== undefined) { 1097 + if (!Array.isArray(fontUrls)) { 1098 + return c.json({ error: "fontUrls must be an array of strings" }, 400); 1099 + } 1100 + for (const url of fontUrls as unknown[]) { 1101 + if (typeof url !== "string" || !url.startsWith("https://")) { 1102 + return c.json({ error: "fontUrls must contain only HTTPS URLs" }, 400); 1103 + } 1104 + } 1105 + } 1106 + 1107 + let theme: typeof themes.$inferSelect; 1108 + try { 1109 + const [row] = await ctx.db 1110 + .select() 1111 + .from(themes) 1112 + .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, themeRkey))) 1113 + .limit(1); 1114 + 1115 + if (!row) { 1116 + return c.json({ error: "Theme not found" }, 404); 1117 + } 1118 + theme = row; 1119 + } catch (error) { 1120 + return handleRouteError(c, error, "Failed to look up theme", { 1121 + operation: "PUT /api/admin/themes/:rkey", 1122 + logger: ctx.logger, 1123 + themeRkey, 1124 + }); 1125 + } 1126 + 1127 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/themes/:rkey"); 1128 + if (agentError) return agentError; 1129 + 1130 + // putRecord is a full replacement — fall back to existing values for 1131 + // optional fields not provided in the request body, to avoid data loss. 1132 + const resolvedCssOverrides = typeof cssOverrides === "string" ? cssOverrides : theme.cssOverrides; 1133 + const resolvedFontUrls = Array.isArray(fontUrls) ? fontUrls : (theme.fontUrls as string[] | null); 1134 + 1135 + try { 1136 + const result = await agent.com.atproto.repo.putRecord({ 1137 + repo: ctx.config.forumDid, 1138 + collection: "space.atbb.forum.theme", 1139 + rkey: theme.rkey, 1140 + record: { 1141 + $type: "space.atbb.forum.theme", 1142 + name: name.trim(), 1143 + colorScheme, 1144 + tokens, 1145 + ...(resolvedCssOverrides != null && { cssOverrides: resolvedCssOverrides }), 1146 + ...(resolvedFontUrls != null && { fontUrls: resolvedFontUrls }), 1147 + createdAt: theme.createdAt.toISOString(), 1148 + updatedAt: new Date().toISOString(), 1149 + }, 1150 + }); 1151 + 1152 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 1153 + } catch (error) { 1154 + return handleRouteError(c, error, "Failed to update theme", { 1155 + operation: "PUT /api/admin/themes/:rkey", 1156 + logger: ctx.logger, 1157 + themeRkey, 1158 + }); 1159 + } 1160 + } 1161 + ); 1162 + 1163 + /** 1164 + * DELETE /api/admin/themes/:rkey 1165 + * 1166 + * Delete a theme. Pre-flight: refuses with 409 if the theme is set as 1167 + * defaultLightTheme or defaultDarkTheme in the theme policy. 1168 + * The firehose indexer removes the DB row asynchronously. 1169 + */ 1170 + app.delete( 1171 + "/themes/:rkey", 1172 + requireAuth(ctx), 1173 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 1174 + async (c) => { 1175 + const themeRkey = c.req.param("rkey").trim(); 1176 + 1177 + let theme: typeof themes.$inferSelect; 1178 + try { 1179 + const [row] = await ctx.db 1180 + .select() 1181 + .from(themes) 1182 + .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, themeRkey))) 1183 + .limit(1); 1184 + 1185 + if (!row) { 1186 + return c.json({ error: "Theme not found" }, 404); 1187 + } 1188 + theme = row; 1189 + } catch (error) { 1190 + return handleRouteError(c, error, "Failed to look up theme", { 1191 + operation: "DELETE /api/admin/themes/:rkey", 1192 + logger: ctx.logger, 1193 + themeRkey, 1194 + }); 1195 + } 1196 + 1197 + // Pre-flight conflict check: refuse if this theme is a policy default 1198 + const themeUri = `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`; 1199 + try { 1200 + const [conflictingPolicy] = await ctx.db 1201 + .select({ id: themePolicies.id }) 1202 + .from(themePolicies) 1203 + .where( 1204 + and( 1205 + eq(themePolicies.did, ctx.config.forumDid), 1206 + or( 1207 + eq(themePolicies.defaultLightThemeUri, themeUri), 1208 + eq(themePolicies.defaultDarkThemeUri, themeUri) 1209 + ) 1210 + ) 1211 + ) 1212 + .limit(1); 1213 + 1214 + if (conflictingPolicy) { 1215 + return c.json( 1216 + { error: "Cannot delete a theme that is currently set as a default. Update the theme policy first." }, 1217 + 409 1218 + ); 1219 + } 1220 + } catch (error) { 1221 + return handleRouteError(c, error, "Failed to check theme policy", { 1222 + operation: "DELETE /api/admin/themes/:rkey", 1223 + logger: ctx.logger, 1224 + themeRkey, 1225 + }); 1226 + } 1227 + 1228 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/themes/:rkey"); 1229 + if (agentError) return agentError; 1230 + 1231 + try { 1232 + await agent.com.atproto.repo.deleteRecord({ 1233 + repo: ctx.config.forumDid, 1234 + collection: "space.atbb.forum.theme", 1235 + rkey: theme.rkey, 1236 + }); 1237 + 1238 + return c.json({ success: true }); 1239 + } catch (error) { 1240 + return handleRouteError(c, error, "Failed to delete theme", { 1241 + operation: "DELETE /api/admin/themes/:rkey", 1242 + logger: ctx.logger, 1243 + themeRkey, 1244 + }); 1245 + } 1246 + } 1247 + ); 1248 + 1249 + /** 1250 + * PUT /api/admin/theme-policy 1251 + * 1252 + * Create or update the themePolicy singleton (rkey: "self") on Forum DID's PDS. 1253 + * Upsert semantics: works whether or not a policy record exists yet. 1254 + * The firehose indexer creates/updates the DB row asynchronously. 1255 + */ 1256 + app.put( 1257 + "/theme-policy", 1258 + requireAuth(ctx), 1259 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 1260 + async (c) => { 1261 + const { body, error: parseError } = await safeParseJsonBody(c); 1262 + if (parseError) return parseError; 1263 + 1264 + const { availableThemes, defaultLightThemeUri, defaultDarkThemeUri, allowUserChoice } = body; 1265 + 1266 + if (!Array.isArray(availableThemes) || availableThemes.length === 0) { 1267 + return c.json({ error: "availableThemes is required and must be a non-empty array" }, 400); 1268 + } 1269 + for (const t of availableThemes as unknown[]) { 1270 + if ( 1271 + typeof t !== "object" || 1272 + t === null || 1273 + typeof (t as Record<string, unknown>).uri !== "string" || 1274 + typeof (t as Record<string, unknown>).cid !== "string" 1275 + ) { 1276 + return c.json({ error: "Each availableThemes entry must have uri and cid string fields" }, 400); 1277 + } 1278 + } 1279 + 1280 + if (typeof defaultLightThemeUri !== "string" || !defaultLightThemeUri.startsWith("at://")) { 1281 + return c.json({ error: "defaultLightThemeUri is required and must be an AT-URI" }, 400); 1282 + } 1283 + if (typeof defaultDarkThemeUri !== "string" || !defaultDarkThemeUri.startsWith("at://")) { 1284 + return c.json({ error: "defaultDarkThemeUri is required and must be an AT-URI" }, 400); 1285 + } 1286 + 1287 + const availableUris = (availableThemes as Array<{ uri: string; cid: string }>).map((t) => t.uri); 1288 + if (!availableUris.includes(defaultLightThemeUri)) { 1289 + return c.json({ error: "defaultLightThemeUri must be present in availableThemes" }, 400); 1290 + } 1291 + if (!availableUris.includes(defaultDarkThemeUri)) { 1292 + return c.json({ error: "defaultDarkThemeUri must be present in availableThemes" }, 400); 1293 + } 1294 + 1295 + const resolvedAllowUserChoice = typeof allowUserChoice === "boolean" ? allowUserChoice : true; 1296 + 1297 + const typedAvailableThemes = availableThemes as Array<{ uri: string; cid: string }>; 1298 + const lightTheme = typedAvailableThemes.find((t) => t.uri === defaultLightThemeUri)!; 1299 + const darkTheme = typedAvailableThemes.find((t) => t.uri === defaultDarkThemeUri)!; 1300 + 1301 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/theme-policy"); 1302 + if (agentError) return agentError; 1303 + 1304 + try { 1305 + const result = await agent.com.atproto.repo.putRecord({ 1306 + repo: ctx.config.forumDid, 1307 + collection: "space.atbb.forum.themePolicy", 1308 + rkey: "self", 1309 + record: { 1310 + $type: "space.atbb.forum.themePolicy", 1311 + availableThemes: typedAvailableThemes.map((t) => ({ 1312 + theme: { uri: t.uri, cid: t.cid }, 1313 + })), 1314 + defaultLightTheme: { theme: { uri: lightTheme.uri, cid: lightTheme.cid } }, 1315 + defaultDarkTheme: { theme: { uri: darkTheme.uri, cid: darkTheme.cid } }, 1316 + allowUserChoice: resolvedAllowUserChoice, 1317 + updatedAt: new Date().toISOString(), 1318 + }, 1319 + }); 1320 + 1321 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 1322 + } catch (error) { 1323 + return handleRouteError(c, error, "Failed to update theme policy", { 1324 + operation: "PUT /api/admin/theme-policy", 1325 + logger: ctx.logger, 978 1326 }); 979 1327 } 980 1328 }
+4 -1
apps/appview/src/routes/index.ts
··· 9 9 import { createAuthRoutes } from "./auth.js"; 10 10 import { createAdminRoutes } from "./admin.js"; 11 11 import { createModRoutes } from "./mod.js"; 12 + import { createThemesRoutes, createThemePolicyRoutes } from "./themes.js"; 12 13 13 14 /** 14 15 * Factory function that creates all API routes with access to app context. ··· 24 25 .route("/topics", createTopicsRoutes(ctx)) 25 26 .route("/posts", createPostsRoutes(ctx)) 26 27 .route("/admin", createAdminRoutes(ctx)) 27 - .route("/mod", createModRoutes(ctx)); 28 + .route("/mod", createModRoutes(ctx)) 29 + .route("/themes", createThemesRoutes(ctx)) 30 + .route("/theme-policy", createThemePolicyRoutes(ctx)); 28 31 } 29 32 30 33 // Export stub routes for tests that don't need database access
+152
apps/appview/src/routes/themes.ts
··· 1 + import { Hono } from "hono"; 2 + import type { AppContext } from "../lib/app-context.js"; 3 + import { themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 4 + import { eq, inArray, and } from "drizzle-orm"; 5 + import { serializeBigInt, serializeDate } from "./helpers.js"; 6 + import { handleRouteError } from "../lib/route-errors.js"; 7 + import { parseAtUri } from "../lib/at-uri.js"; 8 + 9 + type ThemeRow = typeof themes.$inferSelect; 10 + 11 + function serializeThemeSummary(theme: ThemeRow) { 12 + return { 13 + id: serializeBigInt(theme.id), 14 + uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 15 + name: theme.name, 16 + colorScheme: theme.colorScheme, 17 + indexedAt: serializeDate(theme.indexedAt), 18 + }; 19 + } 20 + 21 + function serializeThemeFull(theme: ThemeRow) { 22 + return { 23 + id: serializeBigInt(theme.id), 24 + uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 25 + name: theme.name, 26 + colorScheme: theme.colorScheme, 27 + tokens: theme.tokens, 28 + cssOverrides: theme.cssOverrides ?? null, 29 + fontUrls: (theme.fontUrls as string[] | null) ?? null, 30 + createdAt: serializeDate(theme.createdAt), 31 + indexedAt: serializeDate(theme.indexedAt), 32 + }; 33 + } 34 + 35 + export function createThemesRoutes(ctx: AppContext) { 36 + return new Hono() 37 + .get("/", async (c) => { 38 + try { 39 + // Step 1: Get available theme URIs from this forum's policy 40 + const availableRows = await ctx.db 41 + .select({ themeUri: themePolicyAvailableThemes.themeUri }) 42 + .from(themePolicyAvailableThemes) 43 + .innerJoin( 44 + themePolicies, 45 + eq(themePolicies.id, themePolicyAvailableThemes.policyId) 46 + ) 47 + .where(eq(themePolicies.did, ctx.config.forumDid)); 48 + 49 + if (availableRows.length === 0) { 50 + return c.json({ themes: [] }); 51 + } 52 + 53 + // Step 2: Parse rkeys from AT-URIs 54 + const rkeys = availableRows 55 + .map((r) => parseAtUri(r.themeUri)?.rkey) 56 + .filter((rkey): rkey is string => !!rkey); 57 + 58 + if (rkeys.length === 0) { 59 + return c.json({ themes: [] }); 60 + } 61 + 62 + // Step 3: Fetch matching themes 63 + const themeList = await ctx.db 64 + .select() 65 + .from(themes) 66 + .where( 67 + and( 68 + eq(themes.did, ctx.config.forumDid), 69 + inArray(themes.rkey, rkeys) 70 + ) 71 + ) 72 + .limit(100); 73 + 74 + return c.json({ themes: themeList.map(serializeThemeSummary) }); 75 + } catch (error) { 76 + return handleRouteError(c, error, "Failed to retrieve themes", { 77 + operation: "GET /api/themes", 78 + logger: ctx.logger, 79 + }); 80 + } 81 + }) 82 + .get("/:rkey", async (c) => { 83 + const rkey = c.req.param("rkey").trim(); 84 + if (!rkey) { 85 + return c.json({ error: "Invalid theme rkey" }, 400); 86 + } 87 + 88 + try { 89 + const [theme] = await ctx.db 90 + .select() 91 + .from(themes) 92 + .where( 93 + and( 94 + eq(themes.did, ctx.config.forumDid), 95 + eq(themes.rkey, rkey) 96 + ) 97 + ) 98 + .limit(1); 99 + 100 + if (!theme) { 101 + return c.json({ error: "Theme not found" }, 404); 102 + } 103 + 104 + return c.json(serializeThemeFull(theme)); 105 + } catch (error) { 106 + return handleRouteError(c, error, "Failed to retrieve theme", { 107 + operation: "GET /api/themes/:rkey", 108 + logger: ctx.logger, 109 + themeRkey: rkey, 110 + }); 111 + } 112 + }); 113 + } 114 + 115 + export function createThemePolicyRoutes(ctx: AppContext) { 116 + return new Hono().get("/", async (c) => { 117 + try { 118 + const [policy] = await ctx.db 119 + .select() 120 + .from(themePolicies) 121 + .where(eq(themePolicies.did, ctx.config.forumDid)) 122 + .limit(1); 123 + 124 + if (!policy) { 125 + return c.json({ error: "Theme policy not found" }, 404); 126 + } 127 + 128 + const available = await ctx.db 129 + .select({ 130 + themeUri: themePolicyAvailableThemes.themeUri, 131 + themeCid: themePolicyAvailableThemes.themeCid, 132 + }) 133 + .from(themePolicyAvailableThemes) 134 + .where(eq(themePolicyAvailableThemes.policyId, policy.id)); 135 + 136 + return c.json({ 137 + defaultLightThemeUri: policy.defaultLightThemeUri, 138 + defaultDarkThemeUri: policy.defaultDarkThemeUri, 139 + allowUserChoice: policy.allowUserChoice, 140 + availableThemes: available.map((t) => ({ 141 + uri: t.themeUri, 142 + cid: t.themeCid, 143 + })), 144 + }); 145 + } catch (error) { 146 + return handleRouteError(c, error, "Failed to retrieve theme policy", { 147 + operation: "GET /api/theme-policy", 148 + logger: ctx.logger, 149 + }); 150 + } 151 + }); 152 + }
+31 -31
apps/web/public/static/css/theme.css
··· 752 752 753 753 .post-card__mod-actions { 754 754 display: flex; 755 - gap: var(--space-2); 756 - margin-top: var(--space-2); 757 - padding-top: var(--space-2); 758 - border-top: 1px solid var(--color-border); 755 + gap: var(--space-sm); 756 + margin-top: var(--space-sm); 757 + padding-top: var(--space-sm); 758 + border-top: var(--border-width) solid var(--color-border); 759 759 } 760 760 761 761 .mod-btn { 762 - font-size: 0.75rem; 763 - padding: 0.25rem 0.6rem; 764 - border: 2px solid currentColor; 765 - border-radius: 0; 762 + font-size: var(--font-size-xs); 763 + padding: var(--space-xs) var(--space-sm); 764 + border: var(--border-width) solid currentColor; 765 + border-radius: var(--radius); 766 766 cursor: pointer; 767 767 background: transparent; 768 768 font-family: inherit; 769 - font-weight: 700; 769 + font-weight: var(--font-weight-bold); 770 770 text-transform: uppercase; 771 771 letter-spacing: 0.05em; 772 772 } 773 773 774 774 .mod-btn--hide, 775 775 .mod-btn--lock { 776 - color: var(--color-danger, #d00); 776 + color: var(--color-danger); 777 777 } 778 778 779 779 .mod-btn--hide:hover, 780 780 .mod-btn--lock:hover { 781 - background: var(--color-danger, #d00); 782 - color: #fff; 781 + background: var(--color-danger); 782 + color: var(--color-surface); 783 783 } 784 784 785 785 .mod-btn--unhide, 786 786 .mod-btn--unlock, 787 787 .mod-btn--ban { 788 - color: var(--color-text-muted, #666); 788 + color: var(--color-text-muted); 789 789 } 790 790 791 791 .mod-btn--unhide:hover, 792 792 .mod-btn--unlock:hover, 793 793 .mod-btn--ban:hover { 794 - background: var(--color-text-muted, #666); 795 - color: #fff; 794 + background: var(--color-text-muted); 795 + color: var(--color-surface); 796 796 } 797 797 798 798 .topic-mod-controls { 799 - margin-bottom: var(--space-4); 799 + margin-bottom: var(--space-md); 800 800 } 801 801 802 802 .mod-dialog { 803 - border: 3px solid var(--color-border); 804 - border-radius: 0; 805 - padding: var(--space-6); 803 + border: var(--border-width) solid var(--color-border); 804 + border-radius: var(--radius); 805 + padding: var(--space-lg); 806 806 max-width: 480px; 807 807 width: 90vw; 808 - box-shadow: 6px 6px 0 var(--color-shadow); 808 + box-shadow: var(--card-shadow); 809 809 background: var(--color-bg); 810 810 } 811 811 ··· 815 815 816 816 .mod-dialog__title { 817 817 margin-top: 0; 818 - margin-bottom: var(--space-4); 819 - font-size: 1.25rem; 818 + margin-bottom: var(--space-md); 819 + font-size: var(--font-size-lg); 820 820 } 821 821 822 822 /* ═══════════════════════════════════════════════════════════════════════════ ··· 931 931 } 932 932 933 933 .admin-nav-card__icon { 934 - font-size: var(--font-size-xl, 2rem); 934 + font-size: var(--font-size-xl); 935 935 margin-bottom: var(--space-sm); 936 936 } 937 937 ··· 1005 1005 .structure-page { 1006 1006 display: flex; 1007 1007 flex-direction: column; 1008 - gap: var(--space-6, 1.5rem); 1008 + gap: var(--space-lg); 1009 1009 } 1010 1010 1011 1011 .structure-category { 1012 1012 background: var(--color-surface); 1013 1013 border: var(--border-width) solid var(--color-border); 1014 - border-radius: var(--radius, 0.5rem); 1014 + border-radius: var(--radius); 1015 1015 overflow: hidden; 1016 1016 } 1017 1017 ··· 1051 1051 .structure-board { 1052 1052 background: var(--color-bg); 1053 1053 border: var(--border-width) solid var(--color-border); 1054 - border-radius: var(--radius, 0.375rem); 1054 + border-radius: var(--radius); 1055 1055 } 1056 1056 1057 1057 .structure-board__header { ··· 1094 1094 1095 1095 .structure-add-board { 1096 1096 border: var(--border-width) dashed var(--color-border); 1097 - border-radius: var(--radius, 0.375rem); 1097 + border-radius: var(--radius); 1098 1098 background: transparent; 1099 1099 } 1100 1100 ··· 1109 1109 1110 1110 .structure-add-board__trigger:hover { 1111 1111 background: var(--color-surface); 1112 - border-radius: var(--radius, 0.375rem); 1112 + border-radius: var(--radius); 1113 1113 } 1114 1114 1115 1115 .structure-add-category { ··· 1117 1117 } 1118 1118 1119 1119 .structure-confirm-dialog { 1120 - border-radius: var(--radius, 0.5rem); 1120 + border-radius: var(--radius); 1121 1121 border: var(--border-width) solid var(--color-border); 1122 - padding: var(--space-6, 1.5rem); 1122 + padding: var(--space-lg); 1123 1123 max-width: 24rem; 1124 1124 } 1125 1125 ··· 1142 1142 color: var(--color-danger); 1143 1143 border: var(--border-width) solid var(--color-danger); 1144 1144 border-left-width: calc(var(--border-width) * 3); 1145 - border-radius: var(--radius, 0.5rem); 1145 + border-radius: var(--radius); 1146 1146 padding: var(--space-sm) var(--space-md); 1147 1147 margin-bottom: var(--space-md); 1148 1148 }
+2 -2
apps/web/src/layouts/base.tsx
··· 1 1 import type { FC, PropsWithChildren } from "hono/jsx"; 2 2 import { tokensToCss } from "../lib/theme.js"; 3 - import { neobrutalLight } from "../styles/presets/neobrutal-light.js"; 3 + import neobrutalLight from "../styles/presets/neobrutal-light.json"; 4 4 import type { WebSession } from "../lib/session.js"; 5 5 6 - const ROOT_CSS = `:root { ${tokensToCss(neobrutalLight)} }`; 6 + const ROOT_CSS = `:root { ${tokensToCss(neobrutalLight as Record<string, string>)} }`; 7 7 8 8 const NavContent: FC<{ auth?: WebSession }> = ({ auth }) => ( 9 9 <>
+4 -4
apps/web/src/routes/__tests__/login.test.tsx
··· 39 39 const res = await routes.request("/login"); 40 40 41 41 const html = await res.text(); 42 - expect(html).toContain("Log in with AT Proto"); 42 + expect(html).toContain("Log in"); 43 43 expect(html).toContain('type="submit"'); 44 44 }); 45 45 ··· 52 52 expect(html).toContain('method="get"'); 53 53 }); 54 54 55 - it("renders AT Proto explanation text", async () => { 55 + it("renders Internet Handle explanation text", async () => { 56 56 const routes = await loadLoginRoutes(); 57 57 const res = await routes.request("/login"); 58 58 59 59 const html = await res.text(); 60 - // Should explain what AT Proto login means 61 - expect(html).toContain("AT Protocol"); 60 + // Should explain what Internet Handle login means 61 + expect(html).toContain("Internet Handle"); 62 62 }); 63 63 64 64 it("displays decoded error message from query param", async () => {
+4 -4
apps/web/src/routes/login.tsx
··· 28 28 <BaseLayout title="Sign in — atBB Forum" auth={auth}> 29 29 <PageHeader 30 30 title="Sign in" 31 - description="Sign in with your AT Protocol account." 31 + description="Sign in with your Internet Handle." 32 32 /> 33 33 <div class="login-form"> 34 34 {error && ( ··· 42 42 class="login-form__form" 43 43 > 44 44 <label for="login-handle" class="login-form__label"> 45 - AT Protocol handle 45 + Internet Handle 46 46 </label> 47 47 <input 48 48 type="text" ··· 57 57 aria-describedby="login-hint" 58 58 /> 59 59 <p class="login-form__hint" id="login-hint"> 60 - Use any AT Protocol handle (e.g. <code>alice.bsky.social</code>{" "} 60 + Use any Internet Handle (e.g. <code>alice.bsky.social</code>{" "} 61 61 or a custom domain). You own your posts — they live on your PDS. 62 62 </p> 63 63 <button type="submit" class="login-form__submit"> 64 - Log in with AT Proto 64 + Log in 65 65 </button> 66 66 </form> 67 67 </div>
+60
apps/web/src/styles/presets/__tests__/presets.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import neobrutalLight from "../neobrutal-light.json"; 3 + import neobrutalDark from "../neobrutal-dark.json"; 4 + import { tokensToCss } from "../../../lib/theme.js"; 5 + 6 + const REQUIRED_TOKENS = [ 7 + // Colors 8 + "color-bg", "color-surface", "color-text", "color-text-muted", 9 + "color-primary", "color-primary-hover", "color-secondary", 10 + "color-border", "color-shadow", "color-success", "color-warning", 11 + "color-danger", "color-code-bg", "color-code-text", 12 + // Typography 13 + "font-body", "font-heading", "font-mono", 14 + "font-size-base", "font-size-sm", "font-size-xs", 15 + "font-size-lg", "font-size-xl", "font-size-2xl", 16 + "font-weight-normal", "font-weight-bold", 17 + "line-height-body", "line-height-heading", 18 + // Spacing & layout 19 + "space-xs", "space-sm", "space-md", "space-lg", "space-xl", 20 + "radius", "border-width", "shadow-offset", "content-width", 21 + // Components 22 + "button-radius", "button-shadow", "card-radius", "card-shadow", 23 + "btn-press-hover", "btn-press-active", 24 + "input-radius", "input-border", "nav-height", 25 + ]; 26 + 27 + describe("neobrutal-light preset", () => { 28 + it("contains all required tokens", () => { 29 + for (const token of REQUIRED_TOKENS) { 30 + expect(neobrutalLight, `missing token: ${token}`).toHaveProperty(token); 31 + } 32 + }); 33 + 34 + it("produces valid CSS via tokensToCss", () => { 35 + const css = tokensToCss(neobrutalLight as Record<string, string>); 36 + expect(css).toContain("--color-bg:"); 37 + expect(css).toContain("--font-size-xs:"); 38 + expect(css).toContain("--nav-height:"); 39 + }); 40 + }); 41 + 42 + describe("neobrutal-dark preset", () => { 43 + it("contains all required tokens", () => { 44 + for (const token of REQUIRED_TOKENS) { 45 + expect(neobrutalDark, `missing token: ${token}`).toHaveProperty(token); 46 + } 47 + }); 48 + 49 + it("has a different background color than the light preset", () => { 50 + expect((neobrutalDark as Record<string, string>)["color-bg"]).not.toBe( 51 + (neobrutalLight as Record<string, string>)["color-bg"] 52 + ); 53 + }); 54 + 55 + it("produces valid CSS via tokensToCss", () => { 56 + const css = tokensToCss(neobrutalDark as Record<string, string>); 57 + expect(css).toContain("--color-bg:"); 58 + expect(css).toContain("--font-size-xs:"); 59 + }); 60 + });
+47
apps/web/src/styles/presets/neobrutal-dark.json
··· 1 + { 2 + "color-bg": "#1a1a1a", 3 + "color-surface": "#2d2d2d", 4 + "color-text": "#f5f0e8", 5 + "color-text-muted": "#a0a0a0", 6 + "color-primary": "#ff5c00", 7 + "color-primary-hover": "#ff7a2a", 8 + "color-secondary": "#3a86ff", 9 + "color-border": "#f5f0e8", 10 + "color-shadow": "#000000", 11 + "color-success": "#2ec44a", 12 + "color-warning": "#ffbe0b", 13 + "color-danger": "#ff006e", 14 + "color-code-bg": "#111111", 15 + "color-code-text": "#f5f0e8", 16 + "font-body": "'Space Grotesk', system-ui, sans-serif", 17 + "font-heading": "'Space Grotesk', system-ui, sans-serif", 18 + "font-mono": "'JetBrains Mono', ui-monospace, monospace", 19 + "font-size-base": "16px", 20 + "font-size-sm": "14px", 21 + "font-size-xs": "12px", 22 + "font-size-lg": "20px", 23 + "font-size-xl": "28px", 24 + "font-size-2xl": "36px", 25 + "font-weight-normal": "400", 26 + "font-weight-bold": "700", 27 + "line-height-body": "1.6", 28 + "line-height-heading": "1.2", 29 + "space-xs": "4px", 30 + "space-sm": "8px", 31 + "space-md": "16px", 32 + "space-lg": "24px", 33 + "space-xl": "40px", 34 + "radius": "0px", 35 + "border-width": "2px", 36 + "shadow-offset": "2px", 37 + "content-width": "100%", 38 + "button-radius": "0px", 39 + "button-shadow": "2px 2px 0 var(--color-shadow)", 40 + "card-radius": "0px", 41 + "card-shadow": "4px 4px 0 var(--color-shadow)", 42 + "btn-press-hover": "1px", 43 + "btn-press-active": "2px", 44 + "input-radius": "0px", 45 + "input-border": "2px solid var(--color-border)", 46 + "nav-height": "64px" 47 + }
+4 -8
apps/web/src/styles/presets/neobrutal-light.ts apps/web/src/styles/presets/neobrutal-light.json
··· 1 - // apps/web/src/styles/presets/neobrutal-light.ts 2 - export const neobrutalLight: Record<string, string> = { 3 - // Colors 1 + { 4 2 "color-bg": "#f5f0e8", 5 3 "color-surface": "#ffffff", 6 4 "color-text": "#1a1a1a", ··· 15 13 "color-danger": "#ff006e", 16 14 "color-code-bg": "#1a1a1a", 17 15 "color-code-text": "#f5f0e8", 18 - // Typography 19 16 "font-body": "'Space Grotesk', system-ui, sans-serif", 20 17 "font-heading": "'Space Grotesk', system-ui, sans-serif", 21 18 "font-mono": "'JetBrains Mono', ui-monospace, monospace", 22 19 "font-size-base": "16px", 23 20 "font-size-sm": "14px", 21 + "font-size-xs": "12px", 24 22 "font-size-lg": "20px", 25 23 "font-size-xl": "28px", 26 24 "font-size-2xl": "36px", ··· 28 26 "font-weight-bold": "700", 29 27 "line-height-body": "1.6", 30 28 "line-height-heading": "1.2", 31 - // Spacing & Layout 32 29 "space-xs": "4px", 33 30 "space-sm": "8px", 34 31 "space-md": "16px", ··· 38 35 "border-width": "2px", 39 36 "shadow-offset": "2px", 40 37 "content-width": "100%", 41 - // Components 42 38 "button-radius": "0px", 43 39 "button-shadow": "2px 2px 0 var(--color-shadow)", 44 40 "card-radius": "0px", ··· 47 43 "btn-press-active": "2px", 48 44 "input-radius": "0px", 49 45 "input-border": "2px solid var(--color-border)", 50 - "nav-height": "64px", 51 - }; 46 + "nav-height": "64px" 47 + }
+55
bruno/AppView API/Admin Themes/Create Theme.bru
··· 1 + meta { 2 + name: Create Theme 3 + type: http 4 + seq: 1 5 + } 6 + 7 + post { 8 + url: {{appview_url}}/api/admin/themes 9 + } 10 + 11 + body:json { 12 + { 13 + "name": "Neobrutal Light", 14 + "colorScheme": "light", 15 + "tokens": { 16 + "color-bg": "#f5f0e8", 17 + "color-text": "#1a1a1a", 18 + "color-primary": "#ff5c00" 19 + }, 20 + "fontUrls": ["https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700"] 21 + } 22 + } 23 + 24 + assert { 25 + res.status: eq 201 26 + res.body.uri: isDefined 27 + res.body.cid: isDefined 28 + } 29 + 30 + docs { 31 + Create a new theme record on the Forum DID's PDS. 32 + The firehose indexer creates the DB row asynchronously. 33 + 34 + **Requires:** space.atbb.permission.manageThemes 35 + 36 + Body: 37 + - name (required): Theme display name, non-empty 38 + - colorScheme (required): "light" or "dark" 39 + - tokens (required): Plain object of CSS design token key-value pairs. Values must be strings. 40 + - cssOverrides (optional): Raw CSS string for structural overrides (not rendered until ATB-62 sanitization ships) 41 + - fontUrls (optional): Array of HTTPS URLs for font stylesheets 42 + 43 + Returns (201): 44 + { 45 + "uri": "at://did:plc:.../space.atbb.forum.theme/abc123", 46 + "cid": "bafyrei..." 47 + } 48 + 49 + Error codes: 50 + - 400: Missing name/colorScheme/tokens, invalid colorScheme, non-HTTPS fontUrl, token value not a string, malformed JSON 51 + - 401: Not authenticated 52 + - 403: Missing manageThemes permission 53 + - 500: ForumAgent not configured (server configuration issue) 54 + - 503: ForumAgent not authenticated or PDS network error 55 + }
+37
bruno/AppView API/Admin Themes/Delete Theme.bru
··· 1 + meta { 2 + name: Delete Theme 3 + type: http 4 + seq: 3 5 + } 6 + 7 + delete { 8 + url: {{appview_url}}/api/admin/themes/{{theme_rkey}} 9 + } 10 + 11 + assert { 12 + res.status: eq 200 13 + res.body.success: eq true 14 + } 15 + 16 + docs { 17 + Delete a theme record. Fails with 409 if the theme is currently set as 18 + the defaultLightTheme or defaultDarkTheme in the theme policy. 19 + 20 + **Requires:** space.atbb.permission.manageThemes 21 + 22 + Path params: 23 + - rkey: Theme record key (TID) 24 + 25 + Returns (200): 26 + { 27 + "success": true 28 + } 29 + 30 + Error codes: 31 + - 401: Not authenticated 32 + - 403: Missing manageThemes permission 33 + - 404: Theme not found 34 + - 409: Theme is the current defaultLightTheme or defaultDarkTheme — update theme policy first 35 + - 500: ForumAgent not configured (server configuration issue) 36 + - 503: ForumAgent not authenticated or PDS network error 37 + }
+57
bruno/AppView API/Admin Themes/Update Theme Policy.bru
··· 1 + meta { 2 + name: Update Theme Policy 3 + type: http 4 + seq: 4 5 + } 6 + 7 + put { 8 + url: {{appview_url}}/api/admin/theme-policy 9 + } 10 + 11 + body:json { 12 + { 13 + "availableThemes": [ 14 + { "uri": "at://did:plc:example/space.atbb.forum.theme/3lbllight1", "cid": "bafylight" }, 15 + { "uri": "at://did:plc:example/space.atbb.forum.theme/3lbldark11", "cid": "bafydark" } 16 + ], 17 + "defaultLightThemeUri": "at://did:plc:example/space.atbb.forum.theme/3lbllight1", 18 + "defaultDarkThemeUri": "at://did:plc:example/space.atbb.forum.theme/3lbldark11", 19 + "allowUserChoice": true 20 + } 21 + } 22 + 23 + assert { 24 + res.status: eq 200 25 + res.body.uri: isDefined 26 + res.body.cid: isDefined 27 + } 28 + 29 + docs { 30 + Create or update the themePolicy singleton on the Forum DID's PDS. 31 + Uses upsert semantics: works whether or not a policy record exists yet. 32 + 33 + **Requires:** space.atbb.permission.manageThemes 34 + 35 + Body: 36 + - availableThemes (required): Non-empty array of { uri, cid } theme references. 37 + Both defaultLightThemeUri and defaultDarkThemeUri must be present in this list. 38 + - defaultLightThemeUri (required): AT-URI of the default light-mode theme. 39 + Must be in availableThemes. 40 + - defaultDarkThemeUri (required): AT-URI of the default dark-mode theme. 41 + Must be in availableThemes. 42 + - allowUserChoice (optional, default true): Whether users can pick their own theme. 43 + 44 + Returns (200): 45 + { 46 + "uri": "at://did:plc:.../space.atbb.forum.themePolicy/self", 47 + "cid": "bafyrei..." 48 + } 49 + 50 + Error codes: 51 + - 400: Missing/empty availableThemes, missing defaultLightThemeUri/defaultDarkThemeUri, 52 + default URI not in availableThemes list, malformed JSON 53 + - 401: Not authenticated 54 + - 403: Missing manageThemes permission 55 + - 500: ForumAgent not configured (server configuration issue) 56 + - 503: ForumAgent not authenticated or PDS network error 57 + }
+54
bruno/AppView API/Admin Themes/Update Theme.bru
··· 1 + meta { 2 + name: Update Theme 3 + type: http 4 + seq: 2 5 + } 6 + 7 + put { 8 + url: {{appview_url}}/api/admin/themes/{{theme_rkey}} 9 + } 10 + 11 + body:json { 12 + { 13 + "name": "Neobrutal Light (Updated)", 14 + "colorScheme": "light", 15 + "tokens": { 16 + "color-bg": "#f5f0e8", 17 + "color-text": "#1a1a1a", 18 + "color-primary": "#ff5c00" 19 + } 20 + } 21 + } 22 + 23 + assert { 24 + res.status: eq 200 25 + res.body.uri: isDefined 26 + res.body.cid: isDefined 27 + } 28 + 29 + docs { 30 + Update an existing theme record. Full replacement of the PDS record. 31 + Optional fields (cssOverrides, fontUrls) fall back to their existing values 32 + when omitted from the request body. 33 + 34 + **Requires:** space.atbb.permission.manageThemes 35 + 36 + Path params: 37 + - rkey: Theme record key (TID) 38 + 39 + Body: same as Create Theme (all fields). 40 + 41 + Returns (200): 42 + { 43 + "uri": "at://did:plc:.../space.atbb.forum.theme/abc123", 44 + "cid": "bafyrei..." 45 + } 46 + 47 + Error codes: 48 + - 400: Invalid input (same as Create Theme) 49 + - 401: Not authenticated 50 + - 403: Missing manageThemes permission 51 + - 404: Theme not found 52 + - 500: ForumAgent not configured (server configuration issue) 53 + - 503: ForumAgent not authenticated or PDS network error 54 + }
+35
bruno/AppView API/Themes/Get Theme Policy.bru
··· 1 + meta { 2 + name: Get Theme Policy 3 + type: http 4 + seq: 3 5 + } 6 + 7 + get { 8 + url: {{appview_url}}/api/theme-policy 9 + } 10 + 11 + assert { 12 + res.status: eq 200 13 + res.body.allowUserChoice: isDefined 14 + res.body.availableThemes: isDefined 15 + } 16 + 17 + docs { 18 + Returns the forum's theme policy: which themes are available to users, 19 + the default light and dark themes, and whether users can pick their own. 20 + 21 + Returns: 22 + { 23 + "defaultLightThemeUri": "at://did:plc:.../space.atbb.forum.theme/...", 24 + "defaultDarkThemeUri": "at://did:plc:.../space.atbb.forum.theme/...", 25 + "allowUserChoice": true, 26 + "availableThemes": [ 27 + { "uri": "at://did:plc:.../space.atbb.forum.theme/...", "cid": "bafy..." } 28 + ] 29 + } 30 + 31 + Error codes: 32 + - 404: No theme policy published yet 33 + - 500: Server error 34 + - 503: Database temporarily unavailable 35 + }
+44
bruno/AppView API/Themes/Get Theme.bru
··· 1 + meta { 2 + name: Get Theme 3 + type: http 4 + seq: 2 5 + } 6 + 7 + get { 8 + url: {{appview_url}}/api/themes/{{theme_rkey}} 9 + } 10 + 11 + assert { 12 + res.status: eq 200 13 + res.body.name: isDefined 14 + res.body.tokens: isDefined 15 + } 16 + 17 + docs { 18 + Returns full theme data (name, colorScheme, tokens, cssOverrides, fontUrls) 19 + for the theme identified by its rkey (TID). 20 + 21 + Set the theme_rkey environment variable to a valid theme rkey before running. 22 + 23 + Path params: 24 + - rkey: Theme record key (TID, e.g. 3lblexample) 25 + 26 + Returns: 27 + { 28 + "id": "1", 29 + "uri": "at://did:plc:.../space.atbb.forum.theme/3lblexample", 30 + "name": "Neobrutal Light", 31 + "colorScheme": "light", 32 + "tokens": { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 33 + "cssOverrides": null, 34 + "fontUrls": null, 35 + "createdAt": "2026-03-01T00:00:00.000Z", 36 + "indexedAt": "2026-03-01T00:00:00.000Z" 37 + } 38 + 39 + Error codes: 40 + - 400: Invalid rkey (empty) 41 + - 404: Theme not found 42 + - 500: Server error 43 + - 503: Database temporarily unavailable 44 + }
+36
bruno/AppView API/Themes/List Available Themes.bru
··· 1 + meta { 2 + name: List Available Themes 3 + type: http 4 + seq: 1 5 + } 6 + 7 + get { 8 + url: {{appview_url}}/api/themes 9 + } 10 + 11 + assert { 12 + res.status: eq 200 13 + res.body.themes: isDefined 14 + } 15 + 16 + docs { 17 + Returns themes filtered to those in the forum's themePolicy.availableThemes. 18 + Returns an empty array if no theme policy has been published. 19 + 20 + Returns: 21 + { 22 + "themes": [ 23 + { 24 + "id": "1", 25 + "uri": "at://did:plc:.../space.atbb.forum.theme/...", 26 + "name": "Neobrutal Light", 27 + "colorScheme": "light", 28 + "indexedAt": "2026-03-01T00:00:00.000Z" 29 + } 30 + ] 31 + } 32 + 33 + Error codes: 34 + - 500: Server error 35 + - 503: Database temporarily unavailable 36 + }
+2 -2
docker-compose.example.yml
··· 142 142 # CRITICAL: Generate with: openssl rand -hex 32 143 143 SESSION_SECRET: ${SESSION_SECRET} 144 144 145 - # Optional: Session TTL (defaults to 30 days if not set) 146 - # SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30} 145 + # Optional: Session TTL (defaults to 7 days if not set) 146 + # SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-7} 147 147 148 148 # Optional: Jetstream firehose URL (uses default if not set) 149 149 # JETSTREAM_URL: ${JETSTREAM_URL:-wss://jetstream2.us-east.bsky.network/subscribe}
+4 -1
docs/deployment-guide.md
··· 281 281 | `WEB_PORT` | `3001` | Web UI port (internal) | 282 282 | `APPVIEW_URL` | `http://localhost:3000` | Internal API URL (keep as localhost for single container) | 283 283 | `JETSTREAM_URL` | `wss://jetstream2.us-east.bsky.network/subscribe` | AT Protocol firehose URL | 284 - | `SESSION_TTL_DAYS` | `30` | Session lifetime in days (1-90 range) | 284 + | `SESSION_TTL_DAYS` | `7` | Session lifetime in days (1-90 range) | 285 285 | `REDIS_URL` | (none) | Redis connection string (future: multi-instance deployments) | 286 + | `LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, `error`, `fatal` | 287 + | `SEED_DEFAULT_ROLES` | `true` | Set to `false` to disable automatic role seeding on startup | 288 + | `DEFAULT_MEMBER_ROLE` | (none) | Role name to auto-assign to new memberships | 286 289 287 290 ### Security Best Practices 288 291
+579
docs/plans/2026-03-02-atb-52-css-token-extraction.md
··· 1 + # ATB-52: CSS Token Extraction Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Extract all remaining hardcoded CSS values in `theme.css` into the design token system and ship `neobrutal-light.json` + `neobrutal-dark.json` preset files. 6 + 7 + **Architecture:** `theme.css` already uses `var(--token)` for ~95% of values. Two sections (moderation UI, structure UI) were added separately and never aligned. The preset is currently a TypeScript named export; we convert it to JSON (default import) which is already supported by `resolveJsonModule: true` in `tsconfig.base.json`. `tokensToCss()` and the `BaseLayout` injection are unchanged. 8 + 9 + **Tech Stack:** CSS custom properties, TypeScript JSON imports, Vitest 10 + 11 + **Design doc:** `docs/plans/2026-03-02-css-token-extraction-design.md` 12 + 13 + --- 14 + 15 + ## Running tests 16 + 17 + From the repo root (requires devenv PATH): 18 + 19 + ```bash 20 + PATH=/path/to/repo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/web test 21 + ``` 22 + 23 + Or from inside `apps/web/`: 24 + 25 + ```bash 26 + PATH=/path/to/repo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm exec vitest run 27 + ``` 28 + 29 + If you're in a worktree, the `.devenv` symlink is absent — use the absolute path to the main repo's `.devenv/profile/bin`. 30 + 31 + --- 32 + 33 + ## Task 1: Write failing preset completeness tests 34 + 35 + **Files:** 36 + - Create: `apps/web/src/styles/presets/__tests__/presets.test.ts` 37 + 38 + **Step 1: Create the test file** 39 + 40 + ```typescript 41 + // apps/web/src/styles/presets/__tests__/presets.test.ts 42 + import { describe, it, expect } from "vitest"; 43 + import neobrutalLight from "../neobrutal-light.json"; 44 + import neobrutalDark from "../neobrutal-dark.json"; 45 + import { tokensToCss } from "../../../lib/theme.js"; 46 + 47 + const REQUIRED_TOKENS = [ 48 + // Colors 49 + "color-bg", "color-surface", "color-text", "color-text-muted", 50 + "color-primary", "color-primary-hover", "color-secondary", 51 + "color-border", "color-shadow", "color-success", "color-warning", 52 + "color-danger", "color-code-bg", "color-code-text", 53 + // Typography 54 + "font-body", "font-heading", "font-mono", 55 + "font-size-base", "font-size-sm", "font-size-xs", 56 + "font-size-lg", "font-size-xl", "font-size-2xl", 57 + "font-weight-normal", "font-weight-bold", 58 + "line-height-body", "line-height-heading", 59 + // Spacing & layout 60 + "space-xs", "space-sm", "space-md", "space-lg", "space-xl", 61 + "radius", "border-width", "shadow-offset", "content-width", 62 + // Components 63 + "button-radius", "button-shadow", "card-radius", "card-shadow", 64 + "btn-press-hover", "btn-press-active", 65 + "input-radius", "input-border", "nav-height", 66 + ]; 67 + 68 + describe("neobrutal-light preset", () => { 69 + it("contains all required tokens including font-size-xs", () => { 70 + for (const token of REQUIRED_TOKENS) { 71 + expect(neobrutalLight, `missing token: ${token}`).toHaveProperty(token); 72 + } 73 + }); 74 + 75 + it("produces valid CSS via tokensToCss", () => { 76 + const css = tokensToCss(neobrutalLight as Record<string, string>); 77 + expect(css).toContain("--color-bg:"); 78 + expect(css).toContain("--font-size-xs:"); 79 + expect(css).toContain("--nav-height:"); 80 + }); 81 + }); 82 + 83 + describe("neobrutal-dark preset", () => { 84 + it("contains all required tokens including font-size-xs", () => { 85 + for (const token of REQUIRED_TOKENS) { 86 + expect(neobrutalDark, `missing token: ${token}`).toHaveProperty(token); 87 + } 88 + }); 89 + 90 + it("has a darker background than the light preset", () => { 91 + expect((neobrutalDark as Record<string, string>)["color-bg"]).not.toBe( 92 + (neobrutalLight as Record<string, string>)["color-bg"] 93 + ); 94 + }); 95 + 96 + it("produces valid CSS via tokensToCss", () => { 97 + const css = tokensToCss(neobrutalDark as Record<string, string>); 98 + expect(css).toContain("--color-bg:"); 99 + expect(css).toContain("--font-size-xs:"); 100 + }); 101 + }); 102 + ``` 103 + 104 + **Step 2: Run the test to verify it fails** 105 + 106 + Expected: compile error — `neobrutal-light.json` and `neobrutal-dark.json` don't exist yet. 107 + 108 + ```bash 109 + pnpm exec vitest run src/styles/presets/__tests__/presets.test.ts 110 + ``` 111 + 112 + --- 113 + 114 + ## Task 2: Create `neobrutal-light.json` 115 + 116 + **Files:** 117 + - Create: `apps/web/src/styles/presets/neobrutal-light.json` 118 + 119 + **Step 1: Create the file** 120 + 121 + ```json 122 + { 123 + "color-bg": "#f5f0e8", 124 + "color-surface": "#ffffff", 125 + "color-text": "#1a1a1a", 126 + "color-text-muted": "#555555", 127 + "color-primary": "#ff5c00", 128 + "color-primary-hover": "#e04f00", 129 + "color-secondary": "#3a86ff", 130 + "color-border": "#1a1a1a", 131 + "color-shadow": "#1a1a1a", 132 + "color-success": "#2ec44a", 133 + "color-warning": "#ffbe0b", 134 + "color-danger": "#ff006e", 135 + "color-code-bg": "#1a1a1a", 136 + "color-code-text": "#f5f0e8", 137 + "font-body": "'Space Grotesk', system-ui, sans-serif", 138 + "font-heading": "'Space Grotesk', system-ui, sans-serif", 139 + "font-mono": "'JetBrains Mono', ui-monospace, monospace", 140 + "font-size-base": "16px", 141 + "font-size-sm": "14px", 142 + "font-size-xs": "12px", 143 + "font-size-lg": "20px", 144 + "font-size-xl": "28px", 145 + "font-size-2xl": "36px", 146 + "font-weight-normal": "400", 147 + "font-weight-bold": "700", 148 + "line-height-body": "1.6", 149 + "line-height-heading": "1.2", 150 + "space-xs": "4px", 151 + "space-sm": "8px", 152 + "space-md": "16px", 153 + "space-lg": "24px", 154 + "space-xl": "40px", 155 + "radius": "0px", 156 + "border-width": "2px", 157 + "shadow-offset": "2px", 158 + "content-width": "100%", 159 + "button-radius": "0px", 160 + "button-shadow": "2px 2px 0 var(--color-shadow)", 161 + "card-radius": "0px", 162 + "card-shadow": "4px 4px 0 var(--color-shadow)", 163 + "btn-press-hover": "1px", 164 + "btn-press-active": "2px", 165 + "input-radius": "0px", 166 + "input-border": "2px solid var(--color-border)", 167 + "nav-height": "64px" 168 + } 169 + ``` 170 + 171 + --- 172 + 173 + ## Task 3: Create `neobrutal-dark.json` 174 + 175 + **Files:** 176 + - Create: `apps/web/src/styles/presets/neobrutal-dark.json` 177 + 178 + **Step 1: Create the file** 179 + 180 + Same structure as light; only color tokens differ: 181 + 182 + ```json 183 + { 184 + "color-bg": "#1a1a1a", 185 + "color-surface": "#2d2d2d", 186 + "color-text": "#f5f0e8", 187 + "color-text-muted": "#a0a0a0", 188 + "color-primary": "#ff5c00", 189 + "color-primary-hover": "#ff7a2a", 190 + "color-secondary": "#3a86ff", 191 + "color-border": "#f5f0e8", 192 + "color-shadow": "#000000", 193 + "color-success": "#2ec44a", 194 + "color-warning": "#ffbe0b", 195 + "color-danger": "#ff006e", 196 + "color-code-bg": "#111111", 197 + "color-code-text": "#f5f0e8", 198 + "font-body": "'Space Grotesk', system-ui, sans-serif", 199 + "font-heading": "'Space Grotesk', system-ui, sans-serif", 200 + "font-mono": "'JetBrains Mono', ui-monospace, monospace", 201 + "font-size-base": "16px", 202 + "font-size-sm": "14px", 203 + "font-size-xs": "12px", 204 + "font-size-lg": "20px", 205 + "font-size-xl": "28px", 206 + "font-size-2xl": "36px", 207 + "font-weight-normal": "400", 208 + "font-weight-bold": "700", 209 + "line-height-body": "1.6", 210 + "line-height-heading": "1.2", 211 + "space-xs": "4px", 212 + "space-sm": "8px", 213 + "space-md": "16px", 214 + "space-lg": "24px", 215 + "space-xl": "40px", 216 + "radius": "0px", 217 + "border-width": "2px", 218 + "shadow-offset": "2px", 219 + "content-width": "100%", 220 + "button-radius": "0px", 221 + "button-shadow": "2px 2px 0 var(--color-shadow)", 222 + "card-radius": "0px", 223 + "card-shadow": "4px 4px 0 var(--color-shadow)", 224 + "btn-press-hover": "1px", 225 + "btn-press-active": "2px", 226 + "input-radius": "0px", 227 + "input-border": "2px solid var(--color-border)", 228 + "nav-height": "64px" 229 + } 230 + ``` 231 + 232 + **Step 2: Run the preset tests — expect them to pass now** 233 + 234 + ```bash 235 + pnpm exec vitest run src/styles/presets/__tests__/presets.test.ts 236 + ``` 237 + 238 + Expected: all 6 tests PASS. 239 + 240 + --- 241 + 242 + ## Task 4: Update `base.tsx` to import from JSON 243 + 244 + **Files:** 245 + - Modify: `apps/web/src/layouts/base.tsx:1-6` 246 + 247 + **Step 1: Replace the import and usage** 248 + 249 + Old lines 1–6: 250 + ```typescript 251 + import type { FC, PropsWithChildren } from "hono/jsx"; 252 + import { tokensToCss } from "../lib/theme.js"; 253 + import { neobrutalLight } from "../styles/presets/neobrutal-light.js"; 254 + import type { WebSession } from "../lib/session.js"; 255 + 256 + const ROOT_CSS = `:root { ${tokensToCss(neobrutalLight)} }`; 257 + ``` 258 + 259 + New lines 1–6: 260 + ```typescript 261 + import type { FC, PropsWithChildren } from "hono/jsx"; 262 + import { tokensToCss } from "../lib/theme.js"; 263 + import neobrutalLight from "../styles/presets/neobrutal-light.json"; 264 + import type { WebSession } from "../lib/session.js"; 265 + 266 + const ROOT_CSS = `:root { ${tokensToCss(neobrutalLight as Record<string, string>)} }`; 267 + ``` 268 + 269 + Note: TypeScript infers JSON imports as their literal type (e.g., `{ "color-bg": string; ... }`), which is not directly assignable to `Record<string, string>` without a cast. The `as Record<string, string>` cast is safe here — all values in the JSON are strings. 270 + 271 + **Step 2: Run the base layout tests** 272 + 273 + ```bash 274 + pnpm exec vitest run src/layouts/__tests__/base.test.tsx 275 + ``` 276 + 277 + Expected: all tests PASS (especially "injects neobrutal tokens as :root CSS custom properties" which checks `--color-bg:` and `--color-primary:` appear in the HTML). 278 + 279 + --- 280 + 281 + ## Task 5: Delete `neobrutal-light.ts` 282 + 283 + **Files:** 284 + - Delete: `apps/web/src/styles/presets/neobrutal-light.ts` 285 + 286 + **Step 1: Delete the file** 287 + 288 + ```bash 289 + rm apps/web/src/styles/presets/neobrutal-light.ts 290 + ``` 291 + 292 + **Step 2: Run the full test suite to verify nothing else imported it** 293 + 294 + ```bash 295 + pnpm exec vitest run 296 + ``` 297 + 298 + Expected: all tests PASS. If any test fails with "cannot find module neobrutal-light.js", search for other imports of this file and update them. 299 + 300 + **Step 3: Commit** 301 + 302 + ```bash 303 + git add apps/web/src/styles/presets/ 304 + git add apps/web/src/layouts/base.tsx 305 + git commit -m "feat(web): convert presets to JSON, add neobrutal-dark, add font-size-xs token (ATB-52)" 306 + ``` 307 + 308 + --- 309 + 310 + ## Task 6: Fix moderation UI hardcoded values in `theme.css` 311 + 312 + **Files:** 313 + - Modify: `apps/web/public/static/css/theme.css` (lines ~751–821) 314 + 315 + The existing base layout tests act as the regression harness for CSS — they verify the layout renders and tokens are injected. The visual regression (forum looks identical) requires a manual browser check after this task. 316 + 317 + **Step 1: Replace the entire moderation UI section** 318 + 319 + Find this block (from `/* ─── Moderation UI ──` to just before `/* ═══ RESPONSIVE BREAKPOINTS`): 320 + 321 + ```css 322 + /* ─── Moderation UI ──────────────────────────────────────────────────────── */ 323 + 324 + .post-card__mod-actions { 325 + display: flex; 326 + gap: var(--space-2); 327 + margin-top: var(--space-2); 328 + padding-top: var(--space-2); 329 + border-top: 1px solid var(--color-border); 330 + } 331 + 332 + .mod-btn { 333 + font-size: 0.75rem; 334 + padding: 0.25rem 0.6rem; 335 + border: 2px solid currentColor; 336 + border-radius: 0; 337 + cursor: pointer; 338 + background: transparent; 339 + font-family: inherit; 340 + font-weight: 700; 341 + text-transform: uppercase; 342 + letter-spacing: 0.05em; 343 + } 344 + 345 + .mod-btn--hide, 346 + .mod-btn--lock { 347 + color: var(--color-danger, #d00); 348 + } 349 + 350 + .mod-btn--hide:hover, 351 + .mod-btn--lock:hover { 352 + background: var(--color-danger, #d00); 353 + color: #fff; 354 + } 355 + 356 + .mod-btn--unhide, 357 + .mod-btn--unlock, 358 + .mod-btn--ban { 359 + color: var(--color-text-muted, #666); 360 + } 361 + 362 + .mod-btn--unhide:hover, 363 + .mod-btn--unlock:hover, 364 + .mod-btn--ban:hover { 365 + background: var(--color-text-muted, #666); 366 + color: #fff; 367 + } 368 + 369 + .topic-mod-controls { 370 + margin-bottom: var(--space-4); 371 + } 372 + 373 + .mod-dialog { 374 + border: 3px solid var(--color-border); 375 + border-radius: 0; 376 + padding: var(--space-6); 377 + max-width: 480px; 378 + width: 90vw; 379 + box-shadow: 6px 6px 0 var(--color-shadow); 380 + background: var(--color-bg); 381 + } 382 + 383 + .mod-dialog::backdrop { 384 + background: rgba(0, 0, 0, 0.5); 385 + } 386 + 387 + .mod-dialog__title { 388 + margin-top: 0; 389 + margin-bottom: var(--space-4); 390 + font-size: 1.25rem; 391 + } 392 + ``` 393 + 394 + Replace with: 395 + 396 + ```css 397 + /* ─── Moderation UI ──────────────────────────────────────────────────────── */ 398 + 399 + .post-card__mod-actions { 400 + display: flex; 401 + gap: var(--space-sm); 402 + margin-top: var(--space-sm); 403 + padding-top: var(--space-sm); 404 + border-top: var(--border-width) solid var(--color-border); 405 + } 406 + 407 + .mod-btn { 408 + font-size: var(--font-size-xs); 409 + padding: var(--space-xs) var(--space-sm); 410 + border: var(--border-width) solid currentColor; 411 + border-radius: var(--radius); 412 + cursor: pointer; 413 + background: transparent; 414 + font-family: inherit; 415 + font-weight: var(--font-weight-bold); 416 + text-transform: uppercase; 417 + letter-spacing: 0.05em; 418 + } 419 + 420 + .mod-btn--hide, 421 + .mod-btn--lock { 422 + color: var(--color-danger); 423 + } 424 + 425 + .mod-btn--hide:hover, 426 + .mod-btn--lock:hover { 427 + background: var(--color-danger); 428 + color: var(--color-surface); 429 + } 430 + 431 + .mod-btn--unhide, 432 + .mod-btn--unlock, 433 + .mod-btn--ban { 434 + color: var(--color-text-muted); 435 + } 436 + 437 + .mod-btn--unhide:hover, 438 + .mod-btn--unlock:hover, 439 + .mod-btn--ban:hover { 440 + background: var(--color-text-muted); 441 + color: var(--color-surface); 442 + } 443 + 444 + .topic-mod-controls { 445 + margin-bottom: var(--space-md); 446 + } 447 + 448 + .mod-dialog { 449 + border: var(--border-width) solid var(--color-border); 450 + border-radius: var(--radius); 451 + padding: var(--space-lg); 452 + max-width: 480px; 453 + width: 90vw; 454 + box-shadow: var(--card-shadow); 455 + background: var(--color-bg); 456 + } 457 + 458 + .mod-dialog::backdrop { 459 + background: rgba(0, 0, 0, 0.5); 460 + } 461 + 462 + .mod-dialog__title { 463 + margin-top: 0; 464 + margin-bottom: var(--space-md); 465 + font-size: var(--font-size-lg); 466 + } 467 + ``` 468 + 469 + **Step 2: Run tests** 470 + 471 + ```bash 472 + pnpm exec vitest run 473 + ``` 474 + 475 + Expected: all tests PASS (CSS changes don't affect unit tests; the layout tests still pass). 476 + 477 + --- 478 + 479 + ## Task 7: Fix structure UI hardcoded fallback values in `theme.css` 480 + 481 + **Files:** 482 + - Modify: `apps/web/public/static/css/theme.css` (lines ~1003–1154) 483 + 484 + These are fallback values in `var(--token, fallback)` form — the fallback is a hardcoded value. Remove the fallbacks. 485 + 486 + **Step 1: Apply the following replacements** (use your editor's find-and-replace): 487 + 488 + | Find | Replace | 489 + |------|---------| 490 + | `var(--space-6, 1.5rem)` | `var(--space-lg)` | 491 + | `var(--radius, 0.5rem)` | `var(--radius)` | 492 + | `var(--radius, 0.375rem)` | `var(--radius)` | 493 + | `var(--font-size-xl, 2rem)` | `var(--font-size-xl)` | 494 + 495 + There are multiple occurrences of `var(--radius, ...)` in the structure UI — replace all of them. 496 + 497 + **Step 2: Run tests** 498 + 499 + ```bash 500 + pnpm exec vitest run 501 + ``` 502 + 503 + Expected: all tests PASS. 504 + 505 + **Step 3: Commit** 506 + 507 + ```bash 508 + git add apps/web/public/static/css/theme.css 509 + git commit -m "fix(web): replace hardcoded CSS values with design tokens in mod and structure UI (ATB-52)" 510 + ``` 511 + 512 + --- 513 + 514 + ## Task 8: Verify no hardcoded values remain 515 + 516 + **Step 1: Search for remaining hardcoded values** 517 + 518 + Check for any remaining hardcoded color hex values: 519 + ```bash 520 + grep -n '#[0-9a-fA-F]\{3,6\}' apps/web/public/static/css/theme.css 521 + ``` 522 + 523 + Expected: no matches (the `rgba(0, 0, 0, 0.5)` backdrop uses `rgba` not hex — this is acceptable since it's a structural opacity value that isn't part of the token schema). 524 + 525 + Check for remaining hardcoded pixel sizes outside responsive breakpoints: 526 + ```bash 527 + grep -n '[0-9]\+px' apps/web/public/static/css/theme.css 528 + ``` 529 + 530 + Review the output — pixel values inside `@media (min-width: ...)` breakpoint declarations are expected. Any pixel values in rule bodies are a problem. 531 + 532 + Check for non-standard spacing token names: 533 + ```bash 534 + grep -n 'var(--space-[0-9]' apps/web/public/static/css/theme.css 535 + ``` 536 + 537 + Expected: no matches. 538 + 539 + **Step 2: Manual visual check** 540 + 541 + Start the dev server: 542 + ```bash 543 + pnpm --filter @atbb/web dev 544 + ``` 545 + 546 + Visit each view and confirm visual appearance is unchanged: 547 + - Homepage (category/board list) 548 + - Board page (topic list) 549 + - Topic page (post thread) 550 + - New topic compose form 551 + - Login form 552 + - Admin panel index 553 + - Admin members page 554 + - Admin structure management page 555 + - Any mod action dialogs 556 + 557 + --- 558 + 559 + ## Task 9: Run full test suite and final commit 560 + 561 + **Step 1: Run all tests** 562 + 563 + ```bash 564 + pnpm --filter @atbb/web test 565 + ``` 566 + 567 + Expected: all tests PASS. 568 + 569 + **Step 2: Update Linear** 570 + 571 + Mark ATB-52 as Done and add a comment summarizing: 572 + - `theme.css` now zero hardcoded values 573 + - `neobrutal-light.json` and `neobrutal-dark.json` ship as preset files 574 + - `--font-size-xs: 12px` added to the token schema 575 + - `tokensToCss()` unchanged, fully tested 576 + 577 + **Step 3: Update the plan doc status** 578 + 579 + In `docs/plans/2026-03-02-atb-52-css-token-extraction.md`, update the header status or move to `docs/plans/complete/` when the PR is merged.
+1214
docs/plans/2026-03-02-atb-55-theme-api.md
··· 1 + # ATB-55: Theme Read API Endpoints — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add `themes`, `theme_policies`, and `theme_policy_available_themes` tables, firehose indexers, and three read-only REST endpoints (`GET /api/themes`, `GET /api/themes/:rkey`, `GET /api/theme-policy`) to the AppView. 6 + 7 + **Architecture:** Dual-schema (Postgres + SQLite): `jsonb`/`text({mode:"json"})` for `tokens`, `text[].array()`/`text({mode:"json"})` for `fontUrls`. A normalized join table (`theme_policy_available_themes`) enables SQL-level filtering in `GET /api/themes`. Routes follow the `createXxxRoutes(ctx)` factory pattern; indexer follows the `CollectionConfig<TRecord>` pattern. 8 + 9 + **Tech Stack:** Drizzle ORM, Hono, Vitest, `@atbb/lexicon` (themePolicy types must be regenerated), `drizzle-kit generate` for migrations. 10 + 11 + **Design doc:** `docs/plans/2026-03-02-theme-api-design.md` 12 + 13 + --- 14 + 15 + ## ⚠️ Pre-flight: Environment 16 + 17 + All commands require the devenv PATH. Prefix every command with: 18 + ```bash 19 + export DEVENV="$(git rev-parse --show-toplevel)/.devenv/profile/bin" 20 + export PATH="$DEVENV:/bin:/usr/bin:$PATH" 21 + ``` 22 + 23 + The **lexicon `build:types` step** uses `shopt -s globstar` and **must** run inside `devenv shell`: 24 + ```bash 25 + devenv shell -- pnpm --filter @atbb/lexicon build 26 + ``` 27 + 28 + --- 29 + 30 + ## Task 1: Rebuild Lexicon to Generate `themePolicy` TypeScript Types 31 + 32 + **Why:** `themePolicy.json` was produced in ATB-51 but `themePolicy.ts` was never generated. `SpaceAtbbForumThemePolicy` does not yet exist in `@atbb/lexicon`. 33 + 34 + **Files:** 35 + - No edits needed — just run the build 36 + 37 + **Step 1: Run the lexicon build inside devenv shell** 38 + 39 + ```bash 40 + devenv shell -- pnpm --filter @atbb/lexicon build 41 + ``` 42 + 43 + Expected: build succeeds. The `build:types` step runs `lex gen-api` over all JSON lexicons including `themePolicy.json`. 44 + 45 + **Step 2: Verify themePolicy types were generated** 46 + 47 + ```bash 48 + ls packages/lexicon/dist/types/types/space/atbb/forum/ 49 + ``` 50 + 51 + Expected: `themePolicy.ts` (and `themePolicy.js`, `themePolicy.d.ts`) now present alongside `theme.ts`. 52 + 53 + **Step 3: Verify the export is in the index** 54 + 55 + ```bash 56 + grep "ThemePolicy" packages/lexicon/dist/types/index.ts 57 + ``` 58 + 59 + Expected: lines like `import * as SpaceAtbbForumThemePolicy from ...` and `export * as SpaceAtbbForumThemePolicy`. 60 + 61 + **Step 4: Commit** 62 + 63 + ```bash 64 + git add packages/lexicon/dist/ 65 + git commit -m "chore(lexicon): rebuild dist to generate themePolicy TypeScript types" 66 + ``` 67 + 68 + --- 69 + 70 + ## Task 2: Add Three New Tables to the Postgres Schema 71 + 72 + **Files:** 73 + - Modify: `packages/db/src/schema.ts` 74 + 75 + **Step 1: Add `jsonb` to the Postgres import** 76 + 77 + In `packages/db/src/schema.ts`, change the import from `drizzle-orm/pg-core` to include `jsonb`: 78 + 79 + ```typescript 80 + import { 81 + pgTable, 82 + bigserial, 83 + text, 84 + timestamp, 85 + integer, 86 + boolean, 87 + bigint, 88 + uniqueIndex, 89 + index, 90 + primaryKey, 91 + jsonb, // ADD THIS 92 + } from "drizzle-orm/pg-core"; 93 + ``` 94 + 95 + **Step 2: Append the three new table definitions at the end of `schema.ts`** 96 + 97 + ```typescript 98 + // ── themes ────────────────────────────────────────────── 99 + // Theme definitions, owned by Forum DID. Multiple themes per forum. 100 + // Key: tid (multiple records per repo). 101 + export const themes = pgTable( 102 + "themes", 103 + { 104 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 105 + did: text("did").notNull(), 106 + rkey: text("rkey").notNull(), 107 + cid: text("cid").notNull(), 108 + name: text("name").notNull(), 109 + colorScheme: text("color_scheme").notNull(), // "light" | "dark" 110 + tokens: jsonb("tokens").notNull(), // design token key-value map 111 + cssOverrides: text("css_overrides"), 112 + fontUrls: text("font_urls").array(), 113 + createdAt: timestamp("created_at", { withTimezone: true }), 114 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 115 + }, 116 + (table) => [uniqueIndex("themes_did_rkey_idx").on(table.did, table.rkey)] 117 + ); 118 + 119 + // ── theme_policies ─────────────────────────────────────── 120 + // Singleton theme policy, owned by Forum DID. 121 + // Key: literal:self (rkey is always "self"). 122 + export const themePolicies = pgTable( 123 + "theme_policies", 124 + { 125 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 126 + did: text("did").notNull(), 127 + rkey: text("rkey").notNull(), 128 + cid: text("cid").notNull(), 129 + defaultLightThemeUri: text("default_light_theme_uri").notNull(), 130 + defaultDarkThemeUri: text("default_dark_theme_uri").notNull(), 131 + allowUserChoice: boolean("allow_user_choice").notNull(), 132 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 133 + }, 134 + (table) => [ 135 + uniqueIndex("theme_policies_did_rkey_idx").on(table.did, table.rkey), 136 + ] 137 + ); 138 + 139 + // ── theme_policy_available_themes ──────────────────────── 140 + // Normalized join table: which themes does the policy expose to users? 141 + // ON DELETE CASCADE: deleting a policy row removes all its available-theme rows. 142 + export const themePolicyAvailableThemes = pgTable( 143 + "theme_policy_available_themes", 144 + { 145 + policyId: bigint("policy_id", { mode: "bigint" }) 146 + .notNull() 147 + .references(() => themePolicies.id, { onDelete: "cascade" }), 148 + themeUri: text("theme_uri").notNull(), 149 + themeCid: text("theme_cid").notNull(), 150 + }, 151 + (t) => [primaryKey({ columns: [t.policyId, t.themeUri] })] 152 + ); 153 + ``` 154 + 155 + --- 156 + 157 + ## Task 3: Add the Same Three Tables to the SQLite Schema 158 + 159 + **Files:** 160 + - Modify: `packages/db/src/schema.sqlite.ts` 161 + 162 + **Step 1: Append the three table definitions at the end of the file** 163 + 164 + Note: SQLite has no native `jsonb` or `text[]`. Use `text({ mode: "json" })` — Drizzle auto-serializes objects on insert and deserializes them on select. Use plain `text` for arrays too. 165 + 166 + ```typescript 167 + // ── themes ────────────────────────────────────────────── 168 + export const themes = sqliteTable( 169 + "themes", 170 + { 171 + id: integer("id").primaryKey({ autoIncrement: true }), 172 + did: text("did").notNull(), 173 + rkey: text("rkey").notNull(), 174 + cid: text("cid").notNull(), 175 + name: text("name").notNull(), 176 + colorScheme: text("color_scheme").notNull(), 177 + tokens: text("tokens", { mode: "json" }).notNull(), // auto JSON parse/stringify 178 + cssOverrides: text("css_overrides"), 179 + fontUrls: text("font_urls", { mode: "json" }), // auto JSON parse/stringify 180 + createdAt: integer("created_at", { mode: "timestamp" }), 181 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 182 + }, 183 + (table) => [uniqueIndex("themes_did_rkey_idx").on(table.did, table.rkey)] 184 + ); 185 + 186 + // ── theme_policies ─────────────────────────────────────── 187 + export const themePolicies = sqliteTable( 188 + "theme_policies", 189 + { 190 + id: integer("id").primaryKey({ autoIncrement: true }), 191 + did: text("did").notNull(), 192 + rkey: text("rkey").notNull(), 193 + cid: text("cid").notNull(), 194 + defaultLightThemeUri: text("default_light_theme_uri").notNull(), 195 + defaultDarkThemeUri: text("default_dark_theme_uri").notNull(), 196 + allowUserChoice: integer("allow_user_choice", { mode: "boolean" }).notNull(), 197 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 198 + }, 199 + (table) => [ 200 + uniqueIndex("theme_policies_did_rkey_idx").on(table.did, table.rkey), 201 + ] 202 + ); 203 + 204 + // ── theme_policy_available_themes ──────────────────────── 205 + export const themePolicyAvailableThemes = sqliteTable( 206 + "theme_policy_available_themes", 207 + { 208 + policyId: integer("policy_id") 209 + .notNull() 210 + .references(() => themePolicies.id, { onDelete: "cascade" }), 211 + themeUri: text("theme_uri").notNull(), 212 + themeCid: text("theme_cid").notNull(), 213 + }, 214 + (t) => [primaryKey({ columns: [t.policyId, t.themeUri] })] 215 + ); 216 + ``` 217 + 218 + --- 219 + 220 + ## Task 4: Build `@atbb/db` and Generate Both Migrations 221 + 222 + **Files:** 223 + - Create (generated): `apps/appview/drizzle/0013_*.sql` 224 + - Create (generated): `apps/appview/drizzle-sqlite/0001_*.sql` 225 + 226 + **Step 1: Build the DB package so the new tables are compiled** 227 + 228 + ```bash 229 + pnpm --filter @atbb/db build 230 + ``` 231 + 232 + Expected: `packages/db/dist/` rebuilt with new table exports. 233 + 234 + **Step 2: Generate the Postgres migration** 235 + 236 + ```bash 237 + pnpm --filter @atbb/appview exec drizzle-kit generate --config drizzle.postgres.config.ts 238 + ``` 239 + 240 + Expected: new file `apps/appview/drizzle/0013_*.sql` created with `CREATE TABLE themes`, `CREATE TABLE theme_policies`, `CREATE TABLE theme_policy_available_themes`. 241 + 242 + **Step 3: Generate the SQLite migration** 243 + 244 + ```bash 245 + pnpm --filter @atbb/appview exec drizzle-kit generate --config drizzle.sqlite.config.ts 246 + ``` 247 + 248 + Expected: new file `apps/appview/drizzle-sqlite/0001_*.sql` created with the SQLite equivalents. 249 + 250 + **Step 4: Inspect the generated SQL to verify correctness** 251 + 252 + ```bash 253 + cat apps/appview/drizzle/0013_*.sql 254 + cat apps/appview/drizzle-sqlite/0001_*.sql 255 + ``` 256 + 257 + Check: three `CREATE TABLE` statements, correct column types, `REFERENCES theme_policies(id) ON DELETE CASCADE` on the join table. 258 + 259 + **Step 5: Commit** 260 + 261 + ```bash 262 + git add packages/db/src/ packages/db/dist/ apps/appview/drizzle/ apps/appview/drizzle-sqlite/ 263 + git commit -m "feat(db): add themes, theme_policies, theme_policy_available_themes tables" 264 + ``` 265 + 266 + --- 267 + 268 + ## Task 5: Update Test Context Cleanup for New Tables 269 + 270 + **Files:** 271 + - Modify: `apps/appview/src/lib/__tests__/test-context.ts` 272 + 273 + **Step 1: Add new table imports** 274 + 275 + At the top where tables are imported from `@atbb/db`, add: 276 + 277 + ```typescript 278 + import { 279 + forums, posts, users, categories, memberships, boards, roles, modActions, 280 + backfillProgress, backfillErrors, 281 + themes, themePolicies, themePolicyAvailableThemes, // ADD THESE 282 + } from "@atbb/db"; 283 + ``` 284 + 285 + **Step 2: Add cleanup in `cleanDatabase()` — SQLite branch (delete ALL rows)** 286 + 287 + Inside the `if (isSqlite)` block, add before `await db.delete(forums)`: 288 + 289 + ```typescript 290 + // theme_policy_available_themes cascades from theme_policies, but explicit is clearer 291 + await db.delete(themePolicyAvailableThemes).catch(() => {}); 292 + await db.delete(themePolicies).catch(() => {}); 293 + await db.delete(themes).catch(() => {}); 294 + ``` 295 + 296 + **Step 3: Add cleanup in `cleanDatabase()` — Postgres branch (delete by DID)** 297 + 298 + After the `roles` delete, add: 299 + 300 + ```typescript 301 + // Deleting themePolicies cascades to theme_policy_available_themes 302 + await db.delete(themePolicies).where(eq(themePolicies.did, config.forumDid)).catch(() => {}); 303 + await db.delete(themes).where(eq(themes.did, config.forumDid)).catch(() => {}); 304 + ``` 305 + 306 + **Step 4: Add the same cleanup in `cleanup()`** 307 + 308 + In the `cleanup()` function body, add after the `roles` delete: 309 + 310 + ```typescript 311 + await db.delete(themePolicies).where(eq(themePolicies.did, config.forumDid)); 312 + await db.delete(themes).where(eq(themes.did, config.forumDid)); 313 + ``` 314 + 315 + (The cascade handles `theme_policy_available_themes` automatically in both branches.) 316 + 317 + **Step 5: Verify the test context compiles** 318 + 319 + ```bash 320 + pnpm --filter @atbb/appview exec tsc --noEmit 321 + ``` 322 + 323 + Expected: no errors. 324 + 325 + --- 326 + 327 + ## Task 6: Write Failing Tests for All Three Theme Endpoints 328 + 329 + **Files:** 330 + - Create: `apps/appview/src/routes/__tests__/themes.test.ts` 331 + 332 + Write the full test file — these tests will fail until Task 7 creates the route file. 333 + 334 + ```typescript 335 + import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 336 + import { Hono } from "hono"; 337 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 338 + import { createThemesRoutes, createThemePolicyRoutes } from "../themes.js"; 339 + import { themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 340 + 341 + // ── GET /api/themes ────────────────────────────────────── 342 + 343 + describe("GET /api/themes", () => { 344 + let ctx: TestContext; 345 + let app: Hono; 346 + 347 + beforeEach(async () => { 348 + ctx = await createTestContext(); 349 + app = new Hono() 350 + .route("/themes", createThemesRoutes(ctx)) 351 + .route("/theme-policy", createThemePolicyRoutes(ctx)); 352 + }); 353 + 354 + afterEach(async () => { 355 + await ctx.cleanup(); 356 + }); 357 + 358 + it("returns empty array when no policy exists", async () => { 359 + const res = await app.request("/themes"); 360 + expect(res.status).toBe(200); 361 + const body = await res.json(); 362 + expect(body).toHaveProperty("themes"); 363 + expect(body.themes).toEqual([]); 364 + }); 365 + 366 + it("returns only themes listed in availableThemes (not all themes in DB)", async () => { 367 + await ctx.db.insert(themes).values([ 368 + { 369 + did: ctx.config.forumDid, 370 + rkey: "3lbltheme1aa", 371 + cid: "bafytheme1", 372 + name: "Neobrutal Light", 373 + colorScheme: "light", 374 + tokens: { "color-bg": "#f5f0e8" }, 375 + indexedAt: new Date(), 376 + }, 377 + { 378 + did: ctx.config.forumDid, 379 + rkey: "3lbltheme2bb", 380 + cid: "bafytheme2", 381 + name: "Neobrutal Dark", 382 + colorScheme: "dark", 383 + tokens: { "color-bg": "#1a1a1a" }, 384 + indexedAt: new Date(), 385 + }, 386 + ]); 387 + 388 + const [policy] = await ctx.db 389 + .insert(themePolicies) 390 + .values({ 391 + did: ctx.config.forumDid, 392 + rkey: "self", 393 + cid: "bafypolicy1", 394 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme1aa`, 395 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme2bb`, 396 + allowUserChoice: true, 397 + indexedAt: new Date(), 398 + }) 399 + .returning(); 400 + 401 + // Only expose theme1 (light) — theme2 (dark) stays hidden 402 + await ctx.db.insert(themePolicyAvailableThemes).values({ 403 + policyId: policy.id, 404 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme1aa`, 405 + themeCid: "bafytheme1", 406 + }); 407 + 408 + const res = await app.request("/themes"); 409 + expect(res.status).toBe(200); 410 + const body = await res.json(); 411 + 412 + expect(body.themes).toHaveLength(1); 413 + expect(body.themes[0].name).toBe("Neobrutal Light"); 414 + // theme2 is in DB but NOT in availableThemes — must not appear 415 + const names = body.themes.map((t: any) => t.name); 416 + expect(names).not.toContain("Neobrutal Dark"); 417 + }); 418 + 419 + it("returns summary shape (id, uri, name, colorScheme, indexedAt — no tokens or cssOverrides)", async () => { 420 + await ctx.db.insert(themes).values({ 421 + did: ctx.config.forumDid, 422 + rkey: "3lbltheme3cc", 423 + cid: "bafytheme3", 424 + name: "Clean Light", 425 + colorScheme: "light", 426 + tokens: { "color-bg": "#ffffff" }, 427 + cssOverrides: ".card { border-radius: 4px; }", 428 + indexedAt: new Date(), 429 + }); 430 + 431 + const [policy] = await ctx.db 432 + .insert(themePolicies) 433 + .values({ 434 + did: ctx.config.forumDid, 435 + rkey: "self", 436 + cid: "bafypolicy2", 437 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 438 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 439 + allowUserChoice: true, 440 + indexedAt: new Date(), 441 + }) 442 + .returning(); 443 + 444 + await ctx.db.insert(themePolicyAvailableThemes).values({ 445 + policyId: policy.id, 446 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 447 + themeCid: "bafytheme3", 448 + }); 449 + 450 + const res = await app.request("/themes"); 451 + const body = await res.json(); 452 + const theme = body.themes[0]; 453 + 454 + expect(theme).toHaveProperty("id"); 455 + expect(theme).toHaveProperty("uri"); 456 + expect(theme.uri).toContain("/space.atbb.forum.theme/3lbltheme3cc"); 457 + expect(theme).toHaveProperty("name"); 458 + expect(theme).toHaveProperty("colorScheme"); 459 + expect(theme).toHaveProperty("indexedAt"); 460 + // List endpoint must NOT return full token set 461 + expect(theme).not.toHaveProperty("tokens"); 462 + expect(theme).not.toHaveProperty("cssOverrides"); 463 + expect(theme).not.toHaveProperty("fontUrls"); 464 + }); 465 + 466 + it("returns 503 on database error", async () => { 467 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 468 + throw new Error("Database connection lost"); 469 + }); 470 + 471 + const res = await app.request("/themes"); 472 + expect(res.status).toBe(503); 473 + }); 474 + }); 475 + 476 + // ── GET /api/themes/:rkey ──────────────────────────────── 477 + 478 + describe("GET /api/themes/:rkey", () => { 479 + let ctx: TestContext; 480 + let app: Hono; 481 + 482 + beforeEach(async () => { 483 + ctx = await createTestContext(); 484 + app = new Hono().route("/themes", createThemesRoutes(ctx)); 485 + }); 486 + 487 + afterEach(async () => { 488 + await ctx.cleanup(); 489 + }); 490 + 491 + it("returns 404 for unknown rkey", async () => { 492 + const res = await app.request("/themes/nonexistent"); 493 + expect(res.status).toBe(404); 494 + const body = await res.json(); 495 + expect(body.error).toBeDefined(); 496 + }); 497 + 498 + it("returns full theme data including tokens, cssOverrides, and fontUrls", async () => { 499 + await ctx.db.insert(themes).values({ 500 + did: ctx.config.forumDid, 501 + rkey: "3lblfulltest", 502 + cid: "bafyfull", 503 + name: "Neobrutal Light", 504 + colorScheme: "light", 505 + tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 506 + cssOverrides: ".btn { font-weight: 700; }", 507 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"], 508 + indexedAt: new Date(), 509 + }); 510 + 511 + const res = await app.request("/themes/3lblfulltest"); 512 + expect(res.status).toBe(200); 513 + const body = await res.json(); 514 + 515 + expect(body.name).toBe("Neobrutal Light"); 516 + expect(body.colorScheme).toBe("light"); 517 + expect(body.tokens).toEqual({ "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }); 518 + expect(body.cssOverrides).toBe(".btn { font-weight: 700; }"); 519 + expect(body.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 520 + expect(body.uri).toContain("/space.atbb.forum.theme/3lblfulltest"); 521 + expect(body.indexedAt).toBeDefined(); 522 + }); 523 + 524 + it("returns null for optional fields when not set", async () => { 525 + await ctx.db.insert(themes).values({ 526 + did: ctx.config.forumDid, 527 + rkey: "3lblminimal", 528 + cid: "bafymin", 529 + name: "Minimal", 530 + colorScheme: "light", 531 + tokens: { "color-bg": "#fff" }, 532 + indexedAt: new Date(), 533 + }); 534 + 535 + const res = await app.request("/themes/3lblminimal"); 536 + expect(res.status).toBe(200); 537 + const body = await res.json(); 538 + expect(body.cssOverrides).toBeNull(); 539 + expect(body.fontUrls).toBeNull(); 540 + }); 541 + 542 + it("returns 503 on database error", async () => { 543 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 544 + throw new Error("Database connection lost"); 545 + }); 546 + 547 + const res = await app.request("/themes/any-rkey"); 548 + expect(res.status).toBe(503); 549 + }); 550 + }); 551 + 552 + // ── GET /api/theme-policy ──────────────────────────────── 553 + 554 + describe("GET /api/theme-policy", () => { 555 + let ctx: TestContext; 556 + let app: Hono; 557 + 558 + beforeEach(async () => { 559 + ctx = await createTestContext(); 560 + app = new Hono().route("/theme-policy", createThemePolicyRoutes(ctx)); 561 + }); 562 + 563 + afterEach(async () => { 564 + await ctx.cleanup(); 565 + }); 566 + 567 + it("returns 404 when no policy exists", async () => { 568 + const res = await app.request("/theme-policy"); 569 + expect(res.status).toBe(404); 570 + const body = await res.json(); 571 + expect(body.error).toBeDefined(); 572 + }); 573 + 574 + it("returns policy with correct fields", async () => { 575 + const [policy] = await ctx.db 576 + .insert(themePolicies) 577 + .values({ 578 + did: ctx.config.forumDid, 579 + rkey: "self", 580 + cid: "bafypol", 581 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbllight`, 582 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbldark`, 583 + allowUserChoice: false, 584 + indexedAt: new Date(), 585 + }) 586 + .returning(); 587 + 588 + await ctx.db.insert(themePolicyAvailableThemes).values([ 589 + { 590 + policyId: policy.id, 591 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbllight`, 592 + themeCid: "bafylight", 593 + }, 594 + { 595 + policyId: policy.id, 596 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbldark`, 597 + themeCid: "bafydark", 598 + }, 599 + ]); 600 + 601 + const res = await app.request("/theme-policy"); 602 + expect(res.status).toBe(200); 603 + const body = await res.json(); 604 + 605 + expect(body.defaultLightThemeUri).toContain("3lbllight"); 606 + expect(body.defaultDarkThemeUri).toContain("3lbldark"); 607 + expect(body.allowUserChoice).toBe(false); 608 + expect(body.availableThemes).toHaveLength(2); 609 + expect(body.availableThemes[0]).toHaveProperty("uri"); 610 + expect(body.availableThemes[0]).toHaveProperty("cid"); 611 + }); 612 + 613 + it("returns 503 on database error", async () => { 614 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 615 + throw new Error("Database connection lost"); 616 + }); 617 + 618 + const res = await app.request("/theme-policy"); 619 + expect(res.status).toBe(503); 620 + }); 621 + }); 622 + ``` 623 + 624 + **Step 2: Run to verify the tests fail (expected — route file doesn't exist yet)** 625 + 626 + ```bash 627 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/themes.test.ts 628 + ``` 629 + 630 + Expected: FAIL with import error (module `../themes.js` not found) or TypeScript compile error. 631 + 632 + --- 633 + 634 + ## Task 7: Implement `routes/themes.ts` 635 + 636 + **Files:** 637 + - Create: `apps/appview/src/routes/themes.ts` 638 + 639 + ```typescript 640 + import { Hono } from "hono"; 641 + import type { AppContext } from "../lib/app-context.js"; 642 + import { themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 643 + import { eq, inArray, and } from "drizzle-orm"; 644 + import { serializeBigInt, serializeDate } from "./helpers.js"; 645 + import { handleRouteError } from "../lib/route-errors.js"; 646 + import { parseAtUri } from "../lib/at-uri.js"; 647 + 648 + type ThemeRow = typeof themes.$inferSelect; 649 + 650 + function serializeThemeSummary(theme: ThemeRow) { 651 + return { 652 + id: serializeBigInt(theme.id), 653 + uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 654 + name: theme.name, 655 + colorScheme: theme.colorScheme, 656 + indexedAt: serializeDate(theme.indexedAt), 657 + }; 658 + } 659 + 660 + function serializeThemeFull(theme: ThemeRow) { 661 + return { 662 + id: serializeBigInt(theme.id), 663 + uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 664 + name: theme.name, 665 + colorScheme: theme.colorScheme, 666 + tokens: theme.tokens, 667 + cssOverrides: theme.cssOverrides ?? null, 668 + fontUrls: (theme.fontUrls as string[] | null) ?? null, 669 + createdAt: serializeDate(theme.createdAt), 670 + indexedAt: serializeDate(theme.indexedAt), 671 + }; 672 + } 673 + 674 + export function createThemesRoutes(ctx: AppContext) { 675 + return new Hono() 676 + .get("/", async (c) => { 677 + try { 678 + // Step 1: Get available theme URIs from this forum's policy 679 + const availableRows = await ctx.db 680 + .select({ themeUri: themePolicyAvailableThemes.themeUri }) 681 + .from(themePolicyAvailableThemes) 682 + .innerJoin( 683 + themePolicies, 684 + eq(themePolicies.id, themePolicyAvailableThemes.policyId) 685 + ) 686 + .where(eq(themePolicies.did, ctx.config.forumDid)); 687 + 688 + if (availableRows.length === 0) { 689 + return c.json({ themes: [] }); 690 + } 691 + 692 + // Step 2: Parse rkeys from AT-URIs 693 + const rkeys = availableRows 694 + .map((r) => parseAtUri(r.themeUri)?.rkey) 695 + .filter((rkey): rkey is string => !!rkey); 696 + 697 + if (rkeys.length === 0) { 698 + return c.json({ themes: [] }); 699 + } 700 + 701 + // Step 3: Fetch matching themes 702 + const themeList = await ctx.db 703 + .select() 704 + .from(themes) 705 + .where( 706 + and( 707 + eq(themes.did, ctx.config.forumDid), 708 + inArray(themes.rkey, rkeys) 709 + ) 710 + ) 711 + .limit(100); 712 + 713 + return c.json({ themes: themeList.map(serializeThemeSummary) }); 714 + } catch (error) { 715 + return handleRouteError(c, error, "Failed to retrieve themes", { 716 + operation: "GET /api/themes", 717 + logger: ctx.logger, 718 + }); 719 + } 720 + }) 721 + .get("/:rkey", async (c) => { 722 + const rkey = c.req.param("rkey").trim(); 723 + if (!rkey) { 724 + return c.json({ error: "Invalid theme rkey" }, 400); 725 + } 726 + 727 + try { 728 + const [theme] = await ctx.db 729 + .select() 730 + .from(themes) 731 + .where( 732 + and( 733 + eq(themes.did, ctx.config.forumDid), 734 + eq(themes.rkey, rkey) 735 + ) 736 + ) 737 + .limit(1); 738 + 739 + if (!theme) { 740 + return c.json({ error: "Theme not found" }, 404); 741 + } 742 + 743 + return c.json(serializeThemeFull(theme)); 744 + } catch (error) { 745 + return handleRouteError(c, error, "Failed to retrieve theme", { 746 + operation: "GET /api/themes/:rkey", 747 + logger: ctx.logger, 748 + themeRkey: rkey, 749 + }); 750 + } 751 + }); 752 + } 753 + 754 + export function createThemePolicyRoutes(ctx: AppContext) { 755 + return new Hono().get("/", async (c) => { 756 + try { 757 + const [policy] = await ctx.db 758 + .select() 759 + .from(themePolicies) 760 + .where(eq(themePolicies.did, ctx.config.forumDid)) 761 + .limit(1); 762 + 763 + if (!policy) { 764 + return c.json({ error: "Theme policy not found" }, 404); 765 + } 766 + 767 + const available = await ctx.db 768 + .select({ 769 + themeUri: themePolicyAvailableThemes.themeUri, 770 + themeCid: themePolicyAvailableThemes.themeCid, 771 + }) 772 + .from(themePolicyAvailableThemes) 773 + .where(eq(themePolicyAvailableThemes.policyId, policy.id)); 774 + 775 + return c.json({ 776 + defaultLightThemeUri: policy.defaultLightThemeUri, 777 + defaultDarkThemeUri: policy.defaultDarkThemeUri, 778 + allowUserChoice: policy.allowUserChoice, 779 + availableThemes: available.map((t) => ({ 780 + uri: t.themeUri, 781 + cid: t.themeCid, 782 + })), 783 + }); 784 + } catch (error) { 785 + return handleRouteError(c, error, "Failed to retrieve theme policy", { 786 + operation: "GET /api/theme-policy", 787 + logger: ctx.logger, 788 + }); 789 + } 790 + }); 791 + } 792 + ``` 793 + 794 + --- 795 + 796 + ## Task 8: Register Routes and Run Tests 797 + 798 + **Files:** 799 + - Modify: `apps/appview/src/routes/index.ts` 800 + 801 + **Step 1: Import and register the new routes** 802 + 803 + Add to the imports at the top: 804 + ```typescript 805 + import { createThemesRoutes, createThemePolicyRoutes } from "./themes.js"; 806 + ``` 807 + 808 + Add to the `createApiRoutes` function: 809 + ```typescript 810 + .route("/themes", createThemesRoutes(ctx)) 811 + .route("/theme-policy", createThemePolicyRoutes(ctx)) 812 + ``` 813 + 814 + Also add stub routes for the test-only `apiRoutes` export at the bottom: 815 + ```typescript 816 + const stubThemesRoutes = new Hono().get("/", (c) => c.json({ themes: [] })); 817 + const stubThemePolicyRoutes = new Hono().get("/", (c) => c.json({ error: "not found" }, 404)); 818 + ``` 819 + 820 + And register them on `apiRoutes`: 821 + ```typescript 822 + .route("/themes", stubThemesRoutes) 823 + .route("/theme-policy", stubThemePolicyRoutes) 824 + ``` 825 + 826 + **Step 2: Run the full theme test suite** 827 + 828 + ```bash 829 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/themes.test.ts 830 + ``` 831 + 832 + Expected: ALL tests PASS. 833 + 834 + **Step 3: Run the full test suite to catch regressions** 835 + 836 + ```bash 837 + pnpm --filter @atbb/appview exec vitest run 838 + ``` 839 + 840 + Expected: all tests pass. 841 + 842 + **Step 4: Commit** 843 + 844 + ```bash 845 + git add apps/appview/src/routes/themes.ts apps/appview/src/routes/index.ts \ 846 + apps/appview/src/routes/__tests__/themes.test.ts \ 847 + apps/appview/src/lib/__tests__/test-context.ts 848 + git commit -m "feat(appview): add GET /api/themes, /api/themes/:rkey, /api/theme-policy endpoints (ATB-55)" 849 + ``` 850 + 851 + --- 852 + 853 + ## Task 9: Add Indexer Configs for Theme and ThemePolicy 854 + 855 + **Files:** 856 + - Modify: `apps/appview/src/lib/indexer.ts` 857 + 858 + **Step 1: Add imports for new tables and lexicon types** 859 + 860 + At the top, add to the DB table imports: 861 + ```typescript 862 + import { 863 + posts, forums, categories, boards, users, memberships, modActions, roles, rolePermissions, 864 + themes, themePolicies, themePolicyAvailableThemes, // ADD THESE 865 + } from "@atbb/db"; 866 + ``` 867 + 868 + Add to the lexicon imports: 869 + ```typescript 870 + import { 871 + SpaceAtbbPost as Post, 872 + SpaceAtbbForumForum as Forum, 873 + SpaceAtbbForumCategory as Category, 874 + SpaceAtbbForumBoard as Board, 875 + SpaceAtbbMembership as Membership, 876 + SpaceAtbbModAction as ModAction, 877 + SpaceAtbbForumRole as Role, 878 + SpaceAtbbForumTheme as Theme, // ADD 879 + SpaceAtbbForumThemePolicy as ThemePolicy, // ADD 880 + } from "@atbb/lexicon"; 881 + ``` 882 + 883 + **Step 2: Add `themeConfig` after the existing `boardConfig`** 884 + 885 + ```typescript 886 + private themeConfig: CollectionConfig<Theme.Record> = { 887 + name: "Theme", 888 + table: themes, 889 + deleteStrategy: "hard", 890 + toInsertValues: async (event, record) => ({ 891 + did: event.did, 892 + rkey: event.commit.rkey, 893 + cid: event.commit.cid, 894 + name: record.name, 895 + colorScheme: record.colorScheme as string, 896 + tokens: record.tokens, 897 + cssOverrides: (record.cssOverrides as string | undefined) ?? null, 898 + fontUrls: (record.fontUrls as string[] | undefined) ?? null, 899 + createdAt: record.createdAt ? new Date(record.createdAt as string) : null, 900 + indexedAt: new Date(), 901 + }), 902 + toUpdateValues: async (event, record) => ({ 903 + cid: event.commit.cid, 904 + name: record.name, 905 + colorScheme: record.colorScheme as string, 906 + tokens: record.tokens, 907 + cssOverrides: (record.cssOverrides as string | undefined) ?? null, 908 + fontUrls: (record.fontUrls as string[] | undefined) ?? null, 909 + indexedAt: new Date(), 910 + }), 911 + }; 912 + ``` 913 + 914 + **Step 3: Add `themePolicyConfig` after `themeConfig`** 915 + 916 + Note: `SpaceAtbbForumThemePolicy.Record` will be generated by Task 1. The `availableThemes`, `defaultLightTheme`, and `defaultDarkTheme` fields use `as any` because the lexicon generator emits `[k: string]: unknown` for complex refs. Adjust the casts if the generated interface exposes them explicitly. 917 + 918 + ```typescript 919 + private themePolicyConfig: CollectionConfig<ThemePolicy.Record> = { 920 + name: "ThemePolicy", 921 + table: themePolicies, 922 + deleteStrategy: "hard", 923 + toInsertValues: async (event, record) => ({ 924 + did: event.did, 925 + rkey: event.commit.rkey, 926 + cid: event.commit.cid, 927 + defaultLightThemeUri: (record.defaultLightTheme as any).theme.uri as string, 928 + defaultDarkThemeUri: (record.defaultDarkTheme as any).theme.uri as string, 929 + allowUserChoice: record.allowUserChoice as boolean, 930 + indexedAt: new Date(), 931 + }), 932 + toUpdateValues: async (event, record) => ({ 933 + cid: event.commit.cid, 934 + defaultLightThemeUri: (record.defaultLightTheme as any).theme.uri as string, 935 + defaultDarkThemeUri: (record.defaultDarkTheme as any).theme.uri as string, 936 + allowUserChoice: record.allowUserChoice as boolean, 937 + indexedAt: new Date(), 938 + }), 939 + afterUpsert: async (_event, record, policyId, tx) => { 940 + // Atomically replace all available-theme rows for this policy 941 + await tx 942 + .delete(themePolicyAvailableThemes) 943 + .where(eq(themePolicyAvailableThemes.policyId, policyId)); 944 + 945 + const available = (record.availableThemes as any[]) ?? []; 946 + if (available.length > 0) { 947 + await tx.insert(themePolicyAvailableThemes).values( 948 + available.map((themeRef: any) => ({ 949 + policyId, 950 + themeUri: themeRef.theme.uri as string, 951 + themeCid: themeRef.theme.cid as string, 952 + })) 953 + ); 954 + } 955 + }, 956 + }; 957 + ``` 958 + 959 + **Step 4: Wire the configs to handler methods** 960 + 961 + Find the section where `handlePostCreate`, `handleForumCreate`, etc. are defined (these are generated from `CollectionConfig` via a generic handler). Look for the pattern of how the indexer exposes public handler methods and wire `themeConfig` and `themePolicyConfig` the same way the existing configs are wired. 962 + 963 + Check the bottom of `indexer.ts` for the generic handler wiring. It likely looks like: 964 + ```typescript 965 + handleThemeCreate = this.createHandler(this.themeConfig, "create"); 966 + handleThemeUpdate = this.createHandler(this.themeConfig, "update"); 967 + handleThemeDelete = this.createHandler(this.themeConfig, "delete"); 968 + handleThemePolicyCreate = this.createHandler(this.themePolicyConfig, "create"); 969 + handleThemePolicyUpdate = this.createHandler(this.themePolicyConfig, "update"); 970 + handleThemePolicyDelete = this.createHandler(this.themePolicyConfig, "delete"); 971 + ``` 972 + 973 + > **Note:** Read the bottom 100 lines of `indexer.ts` first to understand the exact wiring pattern, then follow it exactly for theme and themePolicy. 974 + 975 + --- 976 + 977 + ## Task 10: Register Theme and ThemePolicy in the Firehose 978 + 979 + **Files:** 980 + - Modify: `apps/appview/src/lib/firehose.ts` 981 + 982 + **Step 1: Add two `.register()` blocks in `createHandlerRegistry()`** 983 + 984 + After the existing `.register({ collection: "space.atbb.reaction", ... })` block: 985 + 986 + ```typescript 987 + .register({ 988 + collection: "space.atbb.forum.theme", 989 + onCreate: this.createWrappedHandler("handleThemeCreate"), 990 + onUpdate: this.createWrappedHandler("handleThemeUpdate"), 991 + onDelete: this.createWrappedHandler("handleThemeDelete"), 992 + }) 993 + .register({ 994 + collection: "space.atbb.forum.themePolicy", 995 + onCreate: this.createWrappedHandler("handleThemePolicyCreate"), 996 + onUpdate: this.createWrappedHandler("handleThemePolicyUpdate"), 997 + onDelete: this.createWrappedHandler("handleThemePolicyDelete"), 998 + }) 999 + ``` 1000 + 1001 + **Step 2: Verify TypeScript compiles** 1002 + 1003 + ```bash 1004 + pnpm --filter @atbb/appview exec tsc --noEmit 1005 + ``` 1006 + 1007 + Expected: no errors. 1008 + 1009 + **Step 3: Run all tests** 1010 + 1011 + ```bash 1012 + pnpm --filter @atbb/appview exec vitest run 1013 + ``` 1014 + 1015 + Expected: all tests pass (indexer changes don't affect route tests). 1016 + 1017 + **Step 4: Commit** 1018 + 1019 + ```bash 1020 + git add apps/appview/src/lib/indexer.ts apps/appview/src/lib/firehose.ts 1021 + git commit -m "feat(appview): index space.atbb.forum.theme and themePolicy from firehose (ATB-55)" 1022 + ``` 1023 + 1024 + --- 1025 + 1026 + ## Task 11: Write Bruno Collection Files 1027 + 1028 + **Files:** 1029 + - Create: `bruno/AppView API/Themes/List Available Themes.bru` 1030 + - Create: `bruno/AppView API/Themes/Get Theme.bru` 1031 + - Create: `bruno/AppView API/Themes/Get Theme Policy.bru` 1032 + 1033 + **`List Available Themes.bru`:** 1034 + ``` 1035 + meta { 1036 + name: List Available Themes 1037 + type: http 1038 + seq: 1 1039 + } 1040 + 1041 + get { 1042 + url: {{appview_url}}/api/themes 1043 + } 1044 + 1045 + assert { 1046 + res.status: eq 200 1047 + res.body.themes: isDefined 1048 + } 1049 + 1050 + docs { 1051 + Returns themes filtered to those in the forum's themePolicy.availableThemes. 1052 + Returns an empty array if no theme policy has been published. 1053 + 1054 + Returns: 1055 + { 1056 + "themes": [ 1057 + { 1058 + "id": "1", 1059 + "uri": "at://did:plc:.../space.atbb.forum.theme/...", 1060 + "name": "Neobrutal Light", 1061 + "colorScheme": "light", 1062 + "indexedAt": "2026-03-01T00:00:00.000Z" 1063 + } 1064 + ] 1065 + } 1066 + 1067 + Error codes: 1068 + - 500: Server error 1069 + - 503: Database temporarily unavailable 1070 + } 1071 + ``` 1072 + 1073 + **`Get Theme.bru`:** 1074 + ``` 1075 + meta { 1076 + name: Get Theme 1077 + type: http 1078 + seq: 2 1079 + } 1080 + 1081 + get { 1082 + url: {{appview_url}}/api/themes/{{theme_rkey}} 1083 + } 1084 + 1085 + assert { 1086 + res.status: eq 200 1087 + res.body.name: isDefined 1088 + res.body.tokens: isDefined 1089 + } 1090 + 1091 + docs { 1092 + Returns full theme data (name, colorScheme, tokens, cssOverrides, fontUrls) 1093 + for the theme identified by its rkey (TID). 1094 + 1095 + Set the theme_rkey environment variable to a valid theme rkey before running. 1096 + 1097 + Path params: 1098 + - rkey: Theme record key (TID, e.g. 3lblexample) 1099 + 1100 + Returns: 1101 + { 1102 + "id": "1", 1103 + "uri": "at://did:plc:.../space.atbb.forum.theme/3lblexample", 1104 + "name": "Neobrutal Light", 1105 + "colorScheme": "light", 1106 + "tokens": { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 1107 + "cssOverrides": null, 1108 + "fontUrls": null, 1109 + "createdAt": "2026-03-01T00:00:00.000Z", 1110 + "indexedAt": "2026-03-01T00:00:00.000Z" 1111 + } 1112 + 1113 + Error codes: 1114 + - 400: Invalid rkey (empty) 1115 + - 404: Theme not found 1116 + - 500: Server error 1117 + - 503: Database temporarily unavailable 1118 + } 1119 + ``` 1120 + 1121 + **`Get Theme Policy.bru`:** 1122 + ``` 1123 + meta { 1124 + name: Get Theme Policy 1125 + type: http 1126 + seq: 3 1127 + } 1128 + 1129 + get { 1130 + url: {{appview_url}}/api/theme-policy 1131 + } 1132 + 1133 + assert { 1134 + res.status: eq 200 1135 + res.body.allowUserChoice: isDefined 1136 + res.body.availableThemes: isDefined 1137 + } 1138 + 1139 + docs { 1140 + Returns the forum's theme policy: which themes are available to users, 1141 + the default light and dark themes, and whether users can pick their own. 1142 + 1143 + Returns: 1144 + { 1145 + "defaultLightThemeUri": "at://did:plc:.../space.atbb.forum.theme/...", 1146 + "defaultDarkThemeUri": "at://did:plc:.../space.atbb.forum.theme/...", 1147 + "allowUserChoice": true, 1148 + "availableThemes": [ 1149 + { "uri": "at://did:plc:.../space.atbb.forum.theme/...", "cid": "bafy..." } 1150 + ] 1151 + } 1152 + 1153 + Error codes: 1154 + - 404: No theme policy published yet 1155 + - 500: Server error 1156 + - 503: Database temporarily unavailable 1157 + } 1158 + ``` 1159 + 1160 + **Step 2: Commit** 1161 + 1162 + ```bash 1163 + git add bruno/AppView API/Themes/ 1164 + git commit -m "docs(bruno): add Themes API collection (ATB-55)" 1165 + ``` 1166 + 1167 + --- 1168 + 1169 + ## Task 12: Final Verification + Linear Update 1170 + 1171 + **Step 1: Run the full test suite** 1172 + 1173 + ```bash 1174 + pnpm --filter @atbb/appview exec vitest run 1175 + ``` 1176 + 1177 + Expected: all tests pass, no skipped tests. 1178 + 1179 + **Step 2: Typecheck the whole monorepo** 1180 + 1181 + ```bash 1182 + pnpm --filter @atbb/appview exec tsc --noEmit 1183 + pnpm --filter @atbb/db exec tsc --noEmit 1184 + ``` 1185 + 1186 + Expected: no errors. 1187 + 1188 + **Step 3: Update Linear** 1189 + 1190 + - Change ATB-55 status to **In Review** 1191 + - Add a comment: "Implementation complete. PRs: [link]. Endpoints: GET /api/themes, GET /api/themes/:rkey, GET /api/theme-policy. Indexer: space.atbb.forum.theme + space.atbb.forum.themePolicy." 1192 + 1193 + **Step 4: Push and open PR** 1194 + 1195 + ```bash 1196 + git push origin HEAD 1197 + gh pr create \ 1198 + --title "feat(appview): theme read API endpoints (ATB-55)" \ 1199 + --body "$(cat <<'EOF' 1200 + ## Summary 1201 + - Adds `themes`, `theme_policies`, `theme_policy_available_themes` tables to both Postgres and SQLite schemas 1202 + - Indexes `space.atbb.forum.theme` and `space.atbb.forum.themePolicy` from the Jetstream firehose 1203 + - Implements `GET /api/themes`, `GET /api/themes/:rkey`, `GET /api/theme-policy` read endpoints 1204 + - Bruno collection added for all three endpoints 1205 + 1206 + ## Test plan 1207 + - [ ] All theme route tests pass (`vitest run src/routes/__tests__/themes.test.ts`) 1208 + - [ ] Full suite passes (`pnpm --filter @atbb/appview exec vitest run`) 1209 + - [ ] TypeScript compiles clean (`tsc --noEmit`) 1210 + - [ ] Postgres migration reviewed (three new CREATE TABLE statements) 1211 + - [ ] SQLite migration reviewed 1212 + EOF 1213 + )" 1214 + ```
+1807
docs/plans/2026-03-02-atb-58-admin-theme-list-page.md
··· 1 + # ATB-58: Admin Theme List Page — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Build `GET /admin/themes` — an admin page to view, create, duplicate, and delete themes, and manage theme policy — plus two new AppView API endpoints that support it. 6 + 7 + **Architecture:** Two new AppView admin endpoints (`GET /api/admin/themes` for unfiltered theme listing, `POST /api/admin/themes/:rkey/duplicate` for cloning); session.ts gets `canManageThemes`; the web admin page uses the HTML `form` attribute to associate per-card availability checkboxes with a policy `<form>` without nesting. 8 + 9 + **Tech Stack:** Hono (web + appview), Drizzle ORM (postgres.js), Vitest, HTMX-free server-rendered JSX, AT Proto agent (`@atproto/common-web` TID), JSON preset files. 10 + 11 + --- 12 + 13 + ## Before You Start 14 + 15 + Read these files to understand existing patterns before touching any code: 16 + - `apps/appview/src/routes/admin.ts` lines 983–1059 (POST /api/admin/themes — exact pattern to follow) 17 + - `apps/appview/src/routes/__tests__/admin.test.ts` lines 1–80 (test setup: mock structure, `createTestContext`, `describe.sequential`) 18 + - `apps/web/src/routes/admin.tsx` lines 1–210 (imports, types, helper functions, `createAdminRoutes` signature) 19 + - `apps/web/src/routes/__tests__/admin.test.tsx` lines 1–120 (`setupAuthenticatedSession`, `mockFetch` pattern, `loadAdminRoutes`) 20 + - `apps/web/src/lib/session.ts` lines 155–220 (`ADMIN_PERMISSIONS` array, `canManageRoles` — the pattern to clone for `canManageThemes`) 21 + 22 + Test command for appview: `PATH=/path/to/repo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run` 23 + Test command for web: `PATH=/path/to/repo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/web exec vitest run` 24 + Run all: `PATH=/path/to/repo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm turbo test` 25 + 26 + --- 27 + 28 + ## Task 1: AppView — `GET /api/admin/themes` 29 + 30 + Returns all themes for the forum DID (unfiltered by policy) with full token data. 31 + 32 + **Files:** 33 + - Modify: `apps/appview/src/routes/admin.ts` 34 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 35 + 36 + ### Step 1: Write the failing tests 37 + 38 + Add a new `describe` block near the existing theme tests in `admin.test.ts`. Find the section for theme routes (search for `"POST /api/admin/themes"`) and add before it: 39 + 40 + ```typescript 41 + describe("GET /api/admin/themes", () => { 42 + it("returns empty array when no themes exist", async () => { 43 + const res = await app.request("/api/admin/themes"); 44 + expect(res.status).toBe(200); 45 + const body = await res.json(); 46 + expect(body).toHaveProperty("themes"); 47 + expect(body.themes).toEqual([]); 48 + }); 49 + 50 + it("returns all themes regardless of policy availability", async () => { 51 + // Insert two themes but only add one to policy 52 + await ctx.db.insert(themes).values([ 53 + { 54 + did: ctx.config.forumDid, 55 + rkey: "3lbltheme1aa", 56 + cid: "bafytheme1", 57 + name: "Neobrutal Light", 58 + colorScheme: "light", 59 + tokens: { "color-bg": "#f5f0e8" }, 60 + createdAt: new Date(), 61 + indexedAt: new Date(), 62 + }, 63 + { 64 + did: ctx.config.forumDid, 65 + rkey: "3lbltheme2bb", 66 + cid: "bafytheme2", 67 + name: "Neobrutal Dark", 68 + colorScheme: "dark", 69 + tokens: { "color-bg": "#1a1a1a" }, 70 + createdAt: new Date(), 71 + indexedAt: new Date(), 72 + }, 73 + ]); 74 + 75 + const res = await app.request("/api/admin/themes"); 76 + expect(res.status).toBe(200); 77 + const body = await res.json(); 78 + 79 + // Returns BOTH themes — not filtered by policy 80 + expect(body.themes).toHaveLength(2); 81 + expect(body.themes[0]).toMatchObject({ 82 + name: "Neobrutal Light", 83 + colorScheme: "light", 84 + }); 85 + expect(body.themes[0]).toHaveProperty("tokens"); 86 + expect(body.themes[0]).toHaveProperty("uri"); 87 + expect(body.themes[0].uri).toContain("space.atbb.forum.theme"); 88 + }); 89 + 90 + it("returns 401 when not authenticated", async () => { 91 + mockUser = null; 92 + const res = await app.request("/api/admin/themes"); 93 + expect(res.status).toBe(401); 94 + }); 95 + }); 96 + ``` 97 + 98 + ### Step 2: Run tests to verify they fail 99 + 100 + ```bash 101 + pnpm --filter @atbb/appview exec vitest run --reporter verbose 2>&1 | grep -A3 "GET /api/admin/themes" 102 + ``` 103 + 104 + Expected: 3 failures — route does not exist yet. 105 + 106 + ### Step 3: Implement `GET /api/admin/themes` in `apps/appview/src/routes/admin.ts` 107 + 108 + First, add `serializeBigInt` and `serializeDate` to the helpers import at the top of admin.ts: 109 + 110 + ```typescript 111 + // Find this line: 112 + import { parseBigIntParam } from "./helpers.js"; 113 + // Change to: 114 + import { parseBigIntParam, serializeBigInt, serializeDate } from "./helpers.js"; 115 + ``` 116 + 117 + Then add the route inside `createAdminRoutes`, immediately before the `POST /themes` route (before line 991). Find the JSDoc comment `/** POST /api/admin/themes` and insert before it: 118 + 119 + ```typescript 120 + /** 121 + * GET /api/admin/themes 122 + * 123 + * Returns all themes for this forum — no policy filtering. 124 + * Admins need to see all themes, including drafts not yet in the policy. 125 + */ 126 + app.get( 127 + "/themes", 128 + requireAuth(ctx), 129 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 130 + async (c) => { 131 + try { 132 + const themeList = await ctx.db 133 + .select() 134 + .from(themes) 135 + .where(eq(themes.did, ctx.config.forumDid)) 136 + .limit(100); 137 + 138 + return c.json({ 139 + themes: themeList.map((theme) => ({ 140 + id: serializeBigInt(theme.id), 141 + uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 142 + name: theme.name, 143 + colorScheme: theme.colorScheme, 144 + tokens: theme.tokens, 145 + cssOverrides: theme.cssOverrides ?? null, 146 + fontUrls: (theme.fontUrls as string[] | null) ?? null, 147 + createdAt: serializeDate(theme.createdAt), 148 + indexedAt: serializeDate(theme.indexedAt), 149 + })), 150 + }); 151 + } catch (error) { 152 + return handleRouteError(c, error, "Failed to retrieve themes", { 153 + operation: "GET /api/admin/themes", 154 + logger: ctx.logger, 155 + }); 156 + } 157 + } 158 + ); 159 + ``` 160 + 161 + ### Step 4: Run tests to verify they pass 162 + 163 + ```bash 164 + pnpm --filter @atbb/appview exec vitest run --reporter verbose 2>&1 | grep -A3 "GET /api/admin/themes" 165 + ``` 166 + 167 + Expected: 3 passing. 168 + 169 + ### Step 5: Commit 170 + 171 + ```bash 172 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 173 + git commit -m "$(cat <<'EOF' 174 + feat(appview): add GET /api/admin/themes — unfiltered theme list for admin UI (ATB-58) 175 + EOF 176 + )" 177 + ``` 178 + 179 + --- 180 + 181 + ## Task 2: AppView — `POST /api/admin/themes/:rkey/duplicate` 182 + 183 + Clones an existing theme with `" (Copy)"` appended to the name, using a fresh TID rkey. 184 + 185 + **Files:** 186 + - Modify: `apps/appview/src/routes/admin.ts` 187 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 188 + 189 + ### Step 1: Write the failing tests 190 + 191 + Add a new `describe` block in `admin.test.ts` near the other theme tests: 192 + 193 + ```typescript 194 + describe("POST /api/admin/themes/:rkey/duplicate", () => { 195 + beforeEach(async () => { 196 + await ctx.db.insert(themes).values({ 197 + did: ctx.config.forumDid, 198 + rkey: "3lblsource1aa", 199 + cid: "bafysource1", 200 + name: "Neobrutal Light", 201 + colorScheme: "light", 202 + tokens: { "color-bg": "#f5f0e8", "color-primary": "#ff5c00" }, 203 + createdAt: new Date(), 204 + indexedAt: new Date(), 205 + }); 206 + }); 207 + 208 + it("calls putRecord with a new rkey and '(Copy)' name", async () => { 209 + const res = await app.request("/api/admin/themes/3lblsource1aa/duplicate", { 210 + method: "POST", 211 + }); 212 + 213 + expect(res.status).toBe(201); 214 + const body = await res.json(); 215 + expect(body.name).toBe("Neobrutal Light (Copy)"); 216 + expect(body.rkey).toBeDefined(); 217 + expect(body.rkey).not.toBe("3lblsource1aa"); 218 + expect(body.uri).toContain("space.atbb.forum.theme"); 219 + 220 + expect(mockPutRecord).toHaveBeenCalledOnce(); 221 + const putCall = mockPutRecord.mock.calls[0][0]; 222 + expect(putCall.record.name).toBe("Neobrutal Light (Copy)"); 223 + expect(putCall.record.colorScheme).toBe("light"); 224 + expect(putCall.record.tokens).toEqual({ "color-bg": "#f5f0e8", "color-primary": "#ff5c00" }); 225 + expect(putCall.collection).toBe("space.atbb.forum.theme"); 226 + }); 227 + 228 + it("returns 404 when source rkey does not exist", async () => { 229 + const res = await app.request("/api/admin/themes/nonexistent/duplicate", { 230 + method: "POST", 231 + }); 232 + expect(res.status).toBe(404); 233 + }); 234 + 235 + it("returns 401 when not authenticated", async () => { 236 + mockUser = null; 237 + const res = await app.request("/api/admin/themes/3lblsource1aa/duplicate", { 238 + method: "POST", 239 + }); 240 + expect(res.status).toBe(401); 241 + }); 242 + }); 243 + ``` 244 + 245 + ### Step 2: Run tests to verify they fail 246 + 247 + ```bash 248 + pnpm --filter @atbb/appview exec vitest run --reporter verbose 2>&1 | grep -A3 "duplicate" 249 + ``` 250 + 251 + Expected: 3 failures. 252 + 253 + ### Step 3: Implement `POST /api/admin/themes/:rkey/duplicate` in `apps/appview/src/routes/admin.ts` 254 + 255 + Add immediately after the `DELETE /themes/:rkey` route (after the closing of the delete handler, around line 1248). Find the comment `/** PUT /api/admin/theme-policy` and insert before it: 256 + 257 + ```typescript 258 + /** 259 + * POST /api/admin/themes/:rkey/duplicate 260 + * 261 + * Clones an existing theme record with " (Copy)" appended to the name. 262 + * Uses a fresh TID as the new record key. 263 + * The firehose indexer will create the DB row asynchronously. 264 + */ 265 + app.post( 266 + "/themes/:rkey/duplicate", 267 + requireAuth(ctx), 268 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 269 + async (c) => { 270 + const sourceRkey = c.req.param("rkey").trim(); 271 + 272 + let source: typeof themes.$inferSelect; 273 + try { 274 + const [row] = await ctx.db 275 + .select() 276 + .from(themes) 277 + .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, sourceRkey))) 278 + .limit(1); 279 + 280 + if (!row) { 281 + return c.json({ error: "Theme not found" }, 404); 282 + } 283 + source = row; 284 + } catch (error) { 285 + return handleRouteError(c, error, "Failed to look up source theme", { 286 + operation: "POST /api/admin/themes/:rkey/duplicate", 287 + logger: ctx.logger, 288 + sourceRkey, 289 + }); 290 + } 291 + 292 + const { agent, error: agentError } = getForumAgentOrError( 293 + ctx, 294 + c, 295 + "POST /api/admin/themes/:rkey/duplicate" 296 + ); 297 + if (agentError) return agentError; 298 + 299 + const newRkey = TID.nextStr(); 300 + const newName = `${source.name} (Copy)`; 301 + const now = new Date().toISOString(); 302 + 303 + try { 304 + const result = await agent.com.atproto.repo.putRecord({ 305 + repo: ctx.config.forumDid, 306 + collection: "space.atbb.forum.theme", 307 + rkey: newRkey, 308 + record: { 309 + $type: "space.atbb.forum.theme", 310 + name: newName, 311 + colorScheme: source.colorScheme, 312 + tokens: source.tokens, 313 + ...(source.cssOverrides && { cssOverrides: source.cssOverrides }), 314 + ...(source.fontUrls && { fontUrls: source.fontUrls }), 315 + createdAt: now, 316 + }, 317 + }); 318 + 319 + return c.json({ uri: result.data.uri, rkey: newRkey, name: newName }, 201); 320 + } catch (error) { 321 + return handleRouteError(c, error, "Failed to duplicate theme", { 322 + operation: "POST /api/admin/themes/:rkey/duplicate", 323 + logger: ctx.logger, 324 + sourceRkey, 325 + newRkey, 326 + }); 327 + } 328 + } 329 + ); 330 + ``` 331 + 332 + ### Step 4: Run tests to verify they pass 333 + 334 + ```bash 335 + pnpm --filter @atbb/appview exec vitest run --reporter verbose 2>&1 | grep -A3 "duplicate" 336 + ``` 337 + 338 + Expected: 3 passing. 339 + 340 + ### Step 5: Commit 341 + 342 + ```bash 343 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 344 + git commit -m "$(cat <<'EOF' 345 + feat(appview): add POST /api/admin/themes/:rkey/duplicate — clone theme with new TID (ATB-58) 346 + EOF 347 + )" 348 + ``` 349 + 350 + --- 351 + 352 + ## Task 3: Web — Session Layer + Admin Landing Card 353 + 354 + Add `canManageThemes`, add it to `ADMIN_PERMISSIONS`, and add the Themes card to the `/admin` landing page. 355 + 356 + **Files:** 357 + - Modify: `apps/web/src/lib/session.ts` 358 + - Modify: `apps/web/src/routes/admin.tsx` 359 + - Modify: `apps/web/src/routes/__tests__/admin.test.tsx` 360 + 361 + ### Step 1: Write the failing tests 362 + 363 + In `apps/web/src/routes/__tests__/admin.test.tsx`, find the existing test `"grants access and shows all cards for wildcard (*) permission"` and add new tests after the landing page block: 364 + 365 + ```typescript 366 + it("shows Themes card for user with manageThemes permission", async () => { 367 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 368 + const routes = await loadAdminRoutes(); 369 + const res = await routes.request("/admin", { 370 + headers: { cookie: "atbb_session=token" }, 371 + }); 372 + expect(res.status).toBe(200); 373 + const html = await res.text(); 374 + expect(html).toContain('href="/admin/themes"'); 375 + expect(html).toContain("🎨"); 376 + }); 377 + 378 + it("does not show Themes card for user with only manageMembers permission", async () => { 379 + setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 380 + const routes = await loadAdminRoutes(); 381 + const res = await routes.request("/admin", { 382 + headers: { cookie: "atbb_session=token" }, 383 + }); 384 + expect(res.status).toBe(200); 385 + const html = await res.text(); 386 + expect(html).not.toContain('href="/admin/themes"'); 387 + }); 388 + 389 + it("shows Themes card for wildcard (*) permission user", async () => { 390 + setupAuthenticatedSession(["*"]); 391 + const routes = await loadAdminRoutes(); 392 + const res = await routes.request("/admin", { 393 + headers: { cookie: "atbb_session=token" }, 394 + }); 395 + expect(res.status).toBe(200); 396 + const html = await res.text(); 397 + expect(html).toContain('href="/admin/themes"'); 398 + }); 399 + ``` 400 + 401 + ### Step 2: Run tests to verify they fail 402 + 403 + ```bash 404 + pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "Themes card" 405 + ``` 406 + 407 + Expected: 3 failures. 408 + 409 + ### Step 3: Add `canManageThemes` to `apps/web/src/lib/session.ts` 410 + 411 + Find the `ADMIN_PERMISSIONS` array (around line 160) and add the new permission: 412 + 413 + ```typescript 414 + // Find: 415 + const ADMIN_PERMISSIONS = [ 416 + "space.atbb.permission.manageMembers", 417 + "space.atbb.permission.manageCategories", 418 + "space.atbb.permission.moderatePosts", 419 + // ... other permissions 420 + ]; 421 + 422 + // Add "space.atbb.permission.manageThemes" to the array. 423 + ``` 424 + 425 + Then find the last `export function can...` function (should be `canManageRoles`) and add after it: 426 + 427 + ```typescript 428 + /** Returns true if the session grants permission to manage forum themes. */ 429 + export function canManageThemes(auth: WebSessionWithPermissions): boolean { 430 + return ( 431 + auth.authenticated && 432 + (auth.permissions.has("space.atbb.permission.manageThemes") || 433 + auth.permissions.has("*")) 434 + ); 435 + } 436 + ``` 437 + 438 + ### Step 4: Add Themes card to `apps/web/src/routes/admin.tsx` 439 + 440 + **Import `canManageThemes`** — find the import block for session functions and add `canManageThemes`: 441 + 442 + ```typescript 443 + // Find: 444 + import { 445 + getSessionWithPermissions, 446 + hasAnyAdminPermission, 447 + canManageMembers, 448 + canManageCategories, 449 + canViewModLog, 450 + canManageRoles, 451 + } from "../lib/session.js"; 452 + 453 + // Add canManageThemes to the import. 454 + ``` 455 + 456 + **In the `GET /admin` route handler**, find where `canManageRoles` is called to gate a card and add after it: 457 + 458 + ```typescript 459 + const showThemes = canManageThemes(auth); 460 + ``` 461 + 462 + **In the card grid JSX**, find the last admin card and add the Themes card after it: 463 + 464 + ```tsx 465 + {showThemes && ( 466 + <a href="/admin/themes" class="admin-nav-card"> 467 + <Card> 468 + <p class="admin-nav-card__icon" aria-hidden="true">🎨</p> 469 + <p class="admin-nav-card__title">Themes</p> 470 + <p class="admin-nav-card__description"> 471 + Customize forum appearance and color schemes 472 + </p> 473 + </Card> 474 + </a> 475 + )} 476 + ``` 477 + 478 + ### Step 5: Run tests to verify they pass 479 + 480 + ```bash 481 + pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "Themes card" 482 + ``` 483 + 484 + Expected: 3 passing. 485 + 486 + ### Step 6: Commit 487 + 488 + ```bash 489 + git add apps/web/src/lib/session.ts apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx 490 + git commit -m "$(cat <<'EOF' 491 + feat(web): add canManageThemes permission check and Themes card on admin landing (ATB-58) 492 + EOF 493 + )" 494 + ``` 495 + 496 + --- 497 + 498 + ## Task 4: Web — `GET /admin/themes` Page 499 + 500 + The main themes page: auth-gated, fetches all themes + policy, renders cards with swatches and controls. 501 + 502 + **Files:** 503 + - Modify: `apps/web/src/routes/admin.tsx` 504 + - Modify: `apps/web/src/routes/__tests__/admin.test.tsx` 505 + 506 + ### Step 1: Write the failing tests 507 + 508 + Add a new `describe` block in `admin.test.tsx`: 509 + 510 + ```typescript 511 + describe("createAdminRoutes — GET /admin/themes", () => { 512 + beforeEach(() => { 513 + vi.stubGlobal("fetch", mockFetch); 514 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 515 + vi.resetModules(); 516 + }); 517 + 518 + afterEach(() => { 519 + vi.unstubAllGlobals(); 520 + vi.unstubAllEnvs(); 521 + mockFetch.mockReset(); 522 + }); 523 + 524 + function mockResponse(body: unknown, ok = true, status = 200) { 525 + return { 526 + ok, 527 + status, 528 + statusText: ok ? "OK" : "Error", 529 + json: () => Promise.resolve(body), 530 + }; 531 + } 532 + 533 + function setupAuthenticatedSession(permissions: string[]) { 534 + mockFetch.mockResolvedValueOnce( 535 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 536 + ); 537 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 538 + } 539 + 540 + async function loadAdminRoutes() { 541 + const { createAdminRoutes } = await import("../admin.js"); 542 + return createAdminRoutes("http://localhost:3000"); 543 + } 544 + 545 + it("redirects unauthenticated users to /login", async () => { 546 + const routes = await loadAdminRoutes(); 547 + const res = await routes.request("/admin/themes"); 548 + expect(res.status).toBe(302); 549 + expect(res.headers.get("location")).toBe("/login"); 550 + }); 551 + 552 + it("returns 403 for users without manageThemes permission", async () => { 553 + setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 554 + const routes = await loadAdminRoutes(); 555 + const res = await routes.request("/admin/themes", { 556 + headers: { cookie: "atbb_session=token" }, 557 + }); 558 + expect(res.status).toBe(403); 559 + }); 560 + 561 + it("renders theme cards with name, colorScheme badge, and swatches", async () => { 562 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 563 + // GET /api/admin/themes 564 + mockFetch.mockResolvedValueOnce( 565 + mockResponse({ 566 + themes: [ 567 + { 568 + id: "1", 569 + uri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa", 570 + name: "Neobrutal Light", 571 + colorScheme: "light", 572 + tokens: { "color-bg": "#f5f0e8", "color-primary": "#ff5c00", "color-surface": "#ffffff", "color-secondary": "#3a86ff", "color-border": "#1a1a1a" }, 573 + cssOverrides: null, 574 + fontUrls: null, 575 + createdAt: "2026-01-01T00:00:00.000Z", 576 + indexedAt: "2026-01-01T00:00:00.000Z", 577 + }, 578 + ], 579 + }) 580 + ); 581 + // GET /api/theme-policy 582 + mockFetch.mockResolvedValueOnce( 583 + mockResponse({ 584 + defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa", 585 + defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa", 586 + allowUserChoice: true, 587 + availableThemes: [ 588 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa", cid: "bafytheme1" }, 589 + ], 590 + }) 591 + ); 592 + 593 + const routes = await loadAdminRoutes(); 594 + const res = await routes.request("/admin/themes", { 595 + headers: { cookie: "atbb_session=token" }, 596 + }); 597 + expect(res.status).toBe(200); 598 + const html = await res.text(); 599 + expect(html).toContain("Neobrutal Light"); 600 + expect(html).toContain("light"); // colorScheme badge 601 + expect(html).toContain("#f5f0e8"); // color-bg swatch 602 + expect(html).toContain("#ff5c00"); // color-primary swatch 603 + expect(html).toContain("policy-form"); // policy form id 604 + expect(html).toContain("availableThemes"); // checkbox name 605 + }); 606 + 607 + it("shows error banner when ?error= query param is present", async () => { 608 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 609 + mockFetch.mockResolvedValueOnce(mockResponse({ themes: [] })); 610 + mockFetch.mockResolvedValueOnce(mockResponse(null, false, 404)); // no policy yet 611 + 612 + const routes = await loadAdminRoutes(); 613 + const res = await routes.request( 614 + "/admin/themes?error=" + encodeURIComponent("Cannot delete a default theme"), 615 + { headers: { cookie: "atbb_session=token" } } 616 + ); 617 + expect(res.status).toBe(200); 618 + const html = await res.text(); 619 + expect(html).toContain("Cannot delete a default theme"); 620 + expect(html).toContain("structure-error-banner"); 621 + }); 622 + 623 + it("renders create form with preset options", async () => { 624 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 625 + mockFetch.mockResolvedValueOnce(mockResponse({ themes: [] })); 626 + mockFetch.mockResolvedValueOnce(mockResponse(null, false, 404)); 627 + 628 + const routes = await loadAdminRoutes(); 629 + const res = await routes.request("/admin/themes", { 630 + headers: { cookie: "atbb_session=token" }, 631 + }); 632 + expect(res.status).toBe(200); 633 + const html = await res.text(); 634 + expect(html).toContain("neobrutal-light"); 635 + expect(html).toContain("neobrutal-dark"); 636 + expect(html).toContain("blank"); 637 + }); 638 + }); 639 + ``` 640 + 641 + ### Step 2: Run tests to verify they fail 642 + 643 + ```bash 644 + pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "GET /admin/themes" 645 + ``` 646 + 647 + Expected: 5 failures. 648 + 649 + ### Step 3: Add types and imports to `apps/web/src/routes/admin.tsx` 650 + 651 + At the top of admin.tsx, add the JSON imports (after existing imports): 652 + 653 + ```typescript 654 + import neobrutalLight from "../styles/presets/neobrutal-light.json"; 655 + import neobrutalDark from "../styles/presets/neobrutal-dark.json"; 656 + ``` 657 + 658 + Add these interface types near the other interface definitions at the top of admin.tsx: 659 + 660 + ```typescript 661 + interface AdminThemeEntry { 662 + id: string; 663 + uri: string; 664 + name: string; 665 + colorScheme: string; 666 + tokens: Record<string, string>; 667 + cssOverrides: string | null; 668 + fontUrls: string[] | null; 669 + createdAt: string; 670 + indexedAt: string; 671 + } 672 + 673 + interface ThemePolicy { 674 + defaultLightThemeUri: string | null; 675 + defaultDarkThemeUri: string | null; 676 + allowUserChoice: boolean; 677 + availableThemes: Array<{ uri: string; cid: string }>; 678 + } 679 + ``` 680 + 681 + Add the preset map constant (after the imports, before the helper functions): 682 + 683 + ```typescript 684 + const THEME_PRESETS: Record<string, Record<string, string>> = { 685 + "neobrutal-light": neobrutalLight as Record<string, string>, 686 + "neobrutal-dark": neobrutalDark as Record<string, string>, 687 + "blank": {}, 688 + }; 689 + ``` 690 + 691 + ### Step 4: Implement `GET /admin/themes` in `apps/web/src/routes/admin.tsx` 692 + 693 + Find the end of the `GET /admin/roles` route (or whichever is last before the POST routes) and add: 694 + 695 + ```typescript 696 + // ─── Themes ──────────────────────────────────────────────────────────────── 697 + 698 + app.get("/admin/themes", async (c) => { 699 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 700 + 701 + if (!auth.authenticated) { 702 + return c.redirect("/login"); 703 + } 704 + 705 + if (!canManageThemes(auth)) { 706 + return c.html( 707 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 708 + <PageHeader title="Themes" /> 709 + <p>You don&apos;t have permission to manage themes.</p> 710 + </BaseLayout>, 711 + 403 712 + ); 713 + } 714 + 715 + const cookie = c.req.header("cookie") ?? ""; 716 + const errorMsg = c.req.query("error") ?? null; 717 + 718 + let adminThemes: AdminThemeEntry[] = []; 719 + let policy: ThemePolicy | null = null; 720 + 721 + try { 722 + const [themesRes, policyRes] = await Promise.all([ 723 + fetch(`${appviewUrl}/api/admin/themes`, { headers: { Cookie: cookie } }), 724 + fetch(`${appviewUrl}/api/theme-policy`, { headers: { Cookie: cookie } }), 725 + ]); 726 + 727 + if (themesRes.ok) { 728 + const data = (await themesRes.json()) as { themes: AdminThemeEntry[] }; 729 + adminThemes = data.themes; 730 + } else { 731 + logger.error("Failed to fetch admin themes list", { 732 + operation: "GET /admin/themes", 733 + status: themesRes.status, 734 + }); 735 + } 736 + 737 + if (policyRes.ok) { 738 + policy = (await policyRes.json()) as ThemePolicy; 739 + } 740 + // 404 = no policy yet — render page with empty policy (not an error) 741 + } catch (error) { 742 + if (isProgrammingError(error)) throw error; 743 + logger.error("Network error fetching themes data", { 744 + operation: "GET /admin/themes", 745 + error: error instanceof Error ? error.message : String(error), 746 + }); 747 + } 748 + 749 + const availableUris = new Set((policy?.availableThemes ?? []).map((t) => t.uri)); 750 + const lightThemes = adminThemes.filter((t) => t.colorScheme === "light"); 751 + const darkThemes = adminThemes.filter((t) => t.colorScheme === "dark"); 752 + 753 + return c.html( 754 + <BaseLayout title="Themes — atBB Admin" auth={auth}> 755 + <PageHeader title="Themes" /> 756 + 757 + {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 758 + 759 + {adminThemes.length === 0 ? ( 760 + <EmptyState message="No themes yet. Create one below." /> 761 + ) : ( 762 + <div class="structure-list"> 763 + {adminThemes.map((theme) => { 764 + const dialogId = `confirm-delete-theme-${theme.rkey ?? theme.id}`; 765 + const themeRkey = theme.uri.split("/").pop() ?? theme.id; 766 + const swatchTokens = [ 767 + "color-bg", 768 + "color-surface", 769 + "color-primary", 770 + "color-secondary", 771 + "color-border", 772 + ] as const; 773 + 774 + return ( 775 + <div class="structure-item"> 776 + <div class="structure-item__header"> 777 + <div class="structure-item__title"> 778 + <label> 779 + <input 780 + type="checkbox" 781 + form="policy-form" 782 + name="availableThemes" 783 + value={theme.uri} 784 + checked={availableUris.has(theme.uri)} 785 + /> 786 + {" "} 787 + {theme.name} 788 + </label> 789 + <span class={`badge badge--${theme.colorScheme}`}> 790 + {theme.colorScheme} 791 + </span> 792 + </div> 793 + 794 + <div class="theme-swatches" aria-hidden="true"> 795 + {swatchTokens.map((token) => { 796 + const value = theme.tokens[token] ?? "#cccccc"; 797 + const safe = 798 + !value.startsWith("var(") && 799 + !value.includes(";") && 800 + !value.includes("<"); 801 + return ( 802 + <span 803 + class="theme-swatch" 804 + style={safe ? `background:${value}` : "background:#cccccc"} 805 + title={token} 806 + /> 807 + ); 808 + })} 809 + </div> 810 + 811 + <div class="structure-item__actions"> 812 + <span class="btn btn-secondary btn-sm" aria-disabled="true"> 813 + Edit 814 + </span> 815 + 816 + <form 817 + method="post" 818 + action={`/admin/themes/${themeRkey}/duplicate`} 819 + style="display:inline" 820 + > 821 + <button type="submit" class="btn btn-secondary btn-sm"> 822 + Duplicate 823 + </button> 824 + </form> 825 + 826 + <button 827 + type="button" 828 + class="btn btn-danger btn-sm" 829 + onclick={`document.getElementById('${dialogId}').showModal()`} 830 + > 831 + Delete 832 + </button> 833 + </div> 834 + </div> 835 + 836 + <dialog id={dialogId} class="structure-confirm-dialog"> 837 + <p> 838 + Delete theme &quot;{theme.name}&quot;? This cannot be undone. 839 + </p> 840 + <form 841 + method="post" 842 + action={`/admin/themes/${themeRkey}/delete`} 843 + class="dialog-actions" 844 + > 845 + <button type="submit" class="btn btn-danger"> 846 + Delete 847 + </button> 848 + <button 849 + type="button" 850 + class="btn btn-secondary" 851 + onclick={`document.getElementById('${dialogId}').close()`} 852 + > 853 + Cancel 854 + </button> 855 + </form> 856 + </dialog> 857 + </div> 858 + ); 859 + })} 860 + </div> 861 + )} 862 + 863 + {/* Policy form — availability checkboxes on cards associate via form="policy-form" */} 864 + <section class="admin-section"> 865 + <h2>Theme Policy</h2> 866 + <form id="policy-form" method="post" action="/admin/theme-policy"> 867 + <div class="form-group"> 868 + <label for="defaultLightThemeUri">Default Light Theme</label> 869 + <select id="defaultLightThemeUri" name="defaultLightThemeUri"> 870 + <option value="">— none —</option> 871 + {lightThemes.map((t) => ( 872 + <option 873 + value={t.uri} 874 + selected={policy?.defaultLightThemeUri === t.uri} 875 + > 876 + {t.name} 877 + </option> 878 + ))} 879 + </select> 880 + </div> 881 + 882 + <div class="form-group"> 883 + <label for="defaultDarkThemeUri">Default Dark Theme</label> 884 + <select id="defaultDarkThemeUri" name="defaultDarkThemeUri"> 885 + <option value="">— none —</option> 886 + {darkThemes.map((t) => ( 887 + <option 888 + value={t.uri} 889 + selected={policy?.defaultDarkThemeUri === t.uri} 890 + > 891 + {t.name} 892 + </option> 893 + ))} 894 + </select> 895 + </div> 896 + 897 + <div class="form-group"> 898 + <label> 899 + <input 900 + type="checkbox" 901 + name="allowUserChoice" 902 + checked={policy?.allowUserChoice ?? true} 903 + /> 904 + {" "}Allow users to choose their own theme 905 + </label> 906 + </div> 907 + 908 + <p class="form-hint"> 909 + Check themes above to make them available to users. 910 + </p> 911 + <button type="submit" class="btn btn-primary"> 912 + Save Policy 913 + </button> 914 + </form> 915 + </section> 916 + 917 + {/* Create new theme */} 918 + <details class="structure-add-form"> 919 + <summary class="structure-add-form__trigger">+ Create New Theme</summary> 920 + <form 921 + method="post" 922 + action="/admin/themes" 923 + class="structure-edit-form__body" 924 + > 925 + <div class="form-group"> 926 + <label for="new-theme-name">Name</label> 927 + <input 928 + id="new-theme-name" 929 + type="text" 930 + name="name" 931 + required 932 + placeholder="My Custom Theme" 933 + /> 934 + </div> 935 + <div class="form-group"> 936 + <label for="new-theme-scheme">Color Scheme</label> 937 + <select id="new-theme-scheme" name="colorScheme"> 938 + <option value="light">Light</option> 939 + <option value="dark">Dark</option> 940 + </select> 941 + </div> 942 + <div class="form-group"> 943 + <label for="new-theme-preset">Start from Preset</label> 944 + <select id="new-theme-preset" name="preset"> 945 + <option value="neobrutal-light">Neobrutal Light</option> 946 + <option value="neobrutal-dark">Neobrutal Dark</option> 947 + <option value="blank">Blank</option> 948 + </select> 949 + </div> 950 + <button type="submit" class="btn btn-primary"> 951 + Create Theme 952 + </button> 953 + </form> 954 + </details> 955 + </BaseLayout> 956 + ); 957 + }); 958 + ``` 959 + 960 + **Note on `theme.rkey`:** The `AdminThemeEntry` type doesn't have `rkey` — the rkey is embedded in the URI as the last segment (`uri.split("/").pop()`). The code above uses that. Double-check this is correct before submitting. 961 + 962 + ### Step 5: Run tests to verify they pass 963 + 964 + ```bash 965 + pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "GET /admin/themes" 966 + ``` 967 + 968 + Expected: 5 passing. 969 + 970 + ### Step 6: Commit 971 + 972 + ```bash 973 + git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx 974 + git commit -m "$(cat <<'EOF' 975 + feat(web): implement GET /admin/themes page — theme cards, policy form, create form (ATB-58) 976 + EOF 977 + )" 978 + ``` 979 + 980 + --- 981 + 982 + ## Task 5: Web — `POST /admin/themes` (Create with Preset) 983 + 984 + **Files:** 985 + - Modify: `apps/web/src/routes/admin.tsx` 986 + - Modify: `apps/web/src/routes/__tests__/admin.test.tsx` 987 + 988 + ### Step 1: Write the failing tests 989 + 990 + Add to the themes test describe block in `admin.test.tsx`: 991 + 992 + ```typescript 993 + describe("createAdminRoutes — POST /admin/themes", () => { 994 + beforeEach(() => { 995 + vi.stubGlobal("fetch", mockFetch); 996 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 997 + vi.resetModules(); 998 + }); 999 + 1000 + afterEach(() => { 1001 + vi.unstubAllGlobals(); 1002 + vi.unstubAllEnvs(); 1003 + mockFetch.mockReset(); 1004 + }); 1005 + 1006 + function mockResponse(body: unknown, ok = true, status = 200) { 1007 + return { ok, status, json: () => Promise.resolve(body) }; 1008 + } 1009 + 1010 + function setupAuthenticatedSession(permissions: string[]) { 1011 + mockFetch.mockResolvedValueOnce( 1012 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 1013 + ); 1014 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1015 + } 1016 + 1017 + async function loadAdminRoutes() { 1018 + const { createAdminRoutes } = await import("../admin.js"); 1019 + return createAdminRoutes("http://localhost:3000"); 1020 + } 1021 + 1022 + it("creates theme and redirects to /admin/themes on success", async () => { 1023 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 1024 + mockFetch.mockResolvedValueOnce( 1025 + mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.theme/newrkey", cid: "bafy" }, true, 201) 1026 + ); 1027 + 1028 + const routes = await loadAdminRoutes(); 1029 + const res = await routes.request("/admin/themes", { 1030 + method: "POST", 1031 + headers: { 1032 + cookie: "atbb_session=token", 1033 + "content-type": "application/x-www-form-urlencoded", 1034 + }, 1035 + body: "name=My+Theme&colorScheme=light&preset=neobrutal-light", 1036 + }); 1037 + 1038 + expect(res.status).toBe(302); 1039 + expect(res.headers.get("location")).toBe("/admin/themes"); 1040 + }); 1041 + 1042 + it("sends preset tokens to API when preset is neobrutal-light", async () => { 1043 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 1044 + mockFetch.mockResolvedValueOnce( 1045 + mockResponse({ uri: "at://...", cid: "bafy" }, true, 201) 1046 + ); 1047 + 1048 + const routes = await loadAdminRoutes(); 1049 + await routes.request("/admin/themes", { 1050 + method: "POST", 1051 + headers: { 1052 + cookie: "atbb_session=token", 1053 + "content-type": "application/x-www-form-urlencoded", 1054 + }, 1055 + body: "name=Neo&colorScheme=light&preset=neobrutal-light", 1056 + }); 1057 + 1058 + // The API call should contain the preset tokens 1059 + const apiCall = mockFetch.mock.calls[2]; // calls 0+1 = auth, call 2 = POST /api/admin/themes 1060 + const body = JSON.parse(apiCall[1].body); 1061 + expect(body.tokens).toHaveProperty("color-bg"); 1062 + expect(body.tokens["color-bg"]).toBe("#f5f0e8"); 1063 + expect(body.name).toBe("Neo"); 1064 + expect(body.colorScheme).toBe("light"); 1065 + }); 1066 + 1067 + it("sends empty tokens for blank preset", async () => { 1068 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 1069 + mockFetch.mockResolvedValueOnce( 1070 + mockResponse({ uri: "at://...", cid: "bafy" }, true, 201) 1071 + ); 1072 + 1073 + const routes = await loadAdminRoutes(); 1074 + await routes.request("/admin/themes", { 1075 + method: "POST", 1076 + headers: { 1077 + cookie: "atbb_session=token", 1078 + "content-type": "application/x-www-form-urlencoded", 1079 + }, 1080 + body: "name=Blank+Theme&colorScheme=light&preset=blank", 1081 + }); 1082 + 1083 + const apiCall = mockFetch.mock.calls[2]; 1084 + const body = JSON.parse(apiCall[1].body); 1085 + expect(body.tokens).toEqual({}); 1086 + }); 1087 + 1088 + it("redirects with error when name is missing", async () => { 1089 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 1090 + 1091 + const routes = await loadAdminRoutes(); 1092 + const res = await routes.request("/admin/themes", { 1093 + method: "POST", 1094 + headers: { 1095 + cookie: "atbb_session=token", 1096 + "content-type": "application/x-www-form-urlencoded", 1097 + }, 1098 + body: "colorScheme=light&preset=blank", 1099 + }); 1100 + 1101 + expect(res.status).toBe(302); 1102 + expect(res.headers.get("location")).toContain("/admin/themes?error="); 1103 + expect(res.headers.get("location")).toContain("required"); 1104 + }); 1105 + 1106 + it("redirects with error on AppView API failure", async () => { 1107 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 1108 + mockFetch.mockResolvedValueOnce( 1109 + mockResponse({ error: "Theme creation failed" }, false, 500) 1110 + ); 1111 + 1112 + const routes = await loadAdminRoutes(); 1113 + const res = await routes.request("/admin/themes", { 1114 + method: "POST", 1115 + headers: { 1116 + cookie: "atbb_session=token", 1117 + "content-type": "application/x-www-form-urlencoded", 1118 + }, 1119 + body: "name=My+Theme&colorScheme=light&preset=blank", 1120 + }); 1121 + 1122 + expect(res.status).toBe(302); 1123 + expect(res.headers.get("location")).toContain("/admin/themes?error="); 1124 + }); 1125 + }); 1126 + ``` 1127 + 1128 + ### Step 2: Run tests to verify they fail 1129 + 1130 + ```bash 1131 + pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "POST /admin/themes" 1132 + ``` 1133 + 1134 + Expected: 5 failures. 1135 + 1136 + ### Step 3: Implement `POST /admin/themes` in `apps/web/src/routes/admin.tsx` 1137 + 1138 + Add after the `GET /admin/themes` handler: 1139 + 1140 + ```typescript 1141 + app.post("/admin/themes", async (c) => { 1142 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1143 + if (!auth.authenticated) return c.redirect("/login"); 1144 + if (!canManageThemes(auth)) { 1145 + return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 1146 + } 1147 + 1148 + const cookie = c.req.header("cookie") ?? ""; 1149 + 1150 + let body: Record<string, string | File>; 1151 + try { 1152 + body = await c.req.parseBody(); 1153 + } catch (error) { 1154 + if (isProgrammingError(error)) throw error; 1155 + return c.redirect( 1156 + `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 1157 + 302 1158 + ); 1159 + } 1160 + 1161 + const name = typeof body.name === "string" ? body.name.trim() : ""; 1162 + const colorScheme = typeof body.colorScheme === "string" ? body.colorScheme : "light"; 1163 + const preset = typeof body.preset === "string" ? body.preset : "blank"; 1164 + 1165 + if (!name) { 1166 + return c.redirect( 1167 + `/admin/themes?error=${encodeURIComponent("Theme name is required.")}`, 1168 + 302 1169 + ); 1170 + } 1171 + 1172 + const tokens = THEME_PRESETS[preset] ?? {}; 1173 + 1174 + let apiRes: Response; 1175 + try { 1176 + apiRes = await fetch(`${appviewUrl}/api/admin/themes`, { 1177 + method: "POST", 1178 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1179 + body: JSON.stringify({ name, colorScheme, tokens }), 1180 + }); 1181 + } catch (error) { 1182 + if (isProgrammingError(error)) throw error; 1183 + logger.error("Network error creating theme", { 1184 + operation: "POST /admin/themes", 1185 + error: error instanceof Error ? error.message : String(error), 1186 + }); 1187 + return c.redirect( 1188 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1189 + 302 1190 + ); 1191 + } 1192 + 1193 + if (!apiRes.ok) { 1194 + const msg = await extractAppviewError(apiRes, "Failed to create theme. Please try again."); 1195 + return c.redirect( 1196 + `/admin/themes?error=${encodeURIComponent(msg)}`, 1197 + 302 1198 + ); 1199 + } 1200 + 1201 + return c.redirect("/admin/themes", 302); 1202 + }); 1203 + ``` 1204 + 1205 + ### Step 4: Run tests to verify they pass 1206 + 1207 + ```bash 1208 + pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "POST /admin/themes" 1209 + ``` 1210 + 1211 + Expected: 5 passing. 1212 + 1213 + ### Step 5: Commit 1214 + 1215 + ```bash 1216 + git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx 1217 + git commit -m "$(cat <<'EOF' 1218 + feat(web): POST /admin/themes — create theme from preset and redirect (ATB-58) 1219 + EOF 1220 + )" 1221 + ``` 1222 + 1223 + --- 1224 + 1225 + ## Task 6: Web — `POST /admin/themes/:rkey/duplicate` 1226 + 1227 + **Files:** 1228 + - Modify: `apps/web/src/routes/admin.tsx` 1229 + - Modify: `apps/web/src/routes/__tests__/admin.test.tsx` 1230 + 1231 + ### Step 1: Write the failing tests 1232 + 1233 + ```typescript 1234 + describe("createAdminRoutes — POST /admin/themes/:rkey/duplicate", () => { 1235 + beforeEach(() => { 1236 + vi.stubGlobal("fetch", mockFetch); 1237 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1238 + vi.resetModules(); 1239 + }); 1240 + 1241 + afterEach(() => { 1242 + vi.unstubAllGlobals(); 1243 + vi.unstubAllEnvs(); 1244 + mockFetch.mockReset(); 1245 + }); 1246 + 1247 + function mockResponse(body: unknown, ok = true, status = 200) { 1248 + return { ok, status, json: () => Promise.resolve(body) }; 1249 + } 1250 + 1251 + function setupAuthenticatedSession(permissions: string[]) { 1252 + mockFetch.mockResolvedValueOnce( 1253 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 1254 + ); 1255 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1256 + } 1257 + 1258 + async function loadAdminRoutes() { 1259 + const { createAdminRoutes } = await import("../admin.js"); 1260 + return createAdminRoutes("http://localhost:3000"); 1261 + } 1262 + 1263 + it("duplicates theme and redirects to /admin/themes on success", async () => { 1264 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 1265 + mockFetch.mockResolvedValueOnce( 1266 + mockResponse( 1267 + { uri: "at://...", rkey: "newrkey", name: "Neobrutal Light (Copy)" }, 1268 + true, 1269 + 201 1270 + ) 1271 + ); 1272 + 1273 + const routes = await loadAdminRoutes(); 1274 + const res = await routes.request("/admin/themes/3lbltheme1aa/duplicate", { 1275 + method: "POST", 1276 + headers: { cookie: "atbb_session=token" }, 1277 + }); 1278 + 1279 + expect(res.status).toBe(302); 1280 + expect(res.headers.get("location")).toBe("/admin/themes"); 1281 + }); 1282 + 1283 + it("redirects with error on AppView failure", async () => { 1284 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 1285 + mockFetch.mockResolvedValueOnce( 1286 + mockResponse({ error: "Theme not found" }, false, 404) 1287 + ); 1288 + 1289 + const routes = await loadAdminRoutes(); 1290 + const res = await routes.request("/admin/themes/nonexistent/duplicate", { 1291 + method: "POST", 1292 + headers: { cookie: "atbb_session=token" }, 1293 + }); 1294 + 1295 + expect(res.status).toBe(302); 1296 + expect(res.headers.get("location")).toContain("/admin/themes?error="); 1297 + }); 1298 + }); 1299 + ``` 1300 + 1301 + ### Step 2: Run tests to verify they fail 1302 + 1303 + ```bash 1304 + pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "duplicate" 1305 + ``` 1306 + 1307 + ### Step 3: Implement 1308 + 1309 + Add after `POST /admin/themes`: 1310 + 1311 + ```typescript 1312 + app.post("/admin/themes/:rkey/duplicate", async (c) => { 1313 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1314 + if (!auth.authenticated) return c.redirect("/login"); 1315 + if (!canManageThemes(auth)) { 1316 + return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 1317 + } 1318 + 1319 + const cookie = c.req.header("cookie") ?? ""; 1320 + const themeRkey = c.req.param("rkey"); 1321 + 1322 + let apiRes: Response; 1323 + try { 1324 + apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}/duplicate`, { 1325 + method: "POST", 1326 + headers: { Cookie: cookie }, 1327 + }); 1328 + } catch (error) { 1329 + if (isProgrammingError(error)) throw error; 1330 + logger.error("Network error duplicating theme", { 1331 + operation: "POST /admin/themes/:rkey/duplicate", 1332 + themeRkey, 1333 + error: error instanceof Error ? error.message : String(error), 1334 + }); 1335 + return c.redirect( 1336 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1337 + 302 1338 + ); 1339 + } 1340 + 1341 + if (!apiRes.ok) { 1342 + const msg = await extractAppviewError(apiRes, "Failed to duplicate theme. Please try again."); 1343 + return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 1344 + } 1345 + 1346 + return c.redirect("/admin/themes", 302); 1347 + }); 1348 + ``` 1349 + 1350 + ### Step 4: Run tests to verify they pass 1351 + 1352 + ```bash 1353 + pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "duplicate" 1354 + ``` 1355 + 1356 + ### Step 5: Commit 1357 + 1358 + ```bash 1359 + git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx 1360 + git commit -m "$(cat <<'EOF' 1361 + feat(web): POST /admin/themes/:rkey/duplicate — proxy duplicate to AppView (ATB-58) 1362 + EOF 1363 + )" 1364 + ``` 1365 + 1366 + --- 1367 + 1368 + ## Task 7: Web — `POST /admin/themes/:rkey/delete` 1369 + 1370 + **Files:** 1371 + - Modify: `apps/web/src/routes/admin.tsx` 1372 + - Modify: `apps/web/src/routes/__tests__/admin.test.tsx` 1373 + 1374 + ### Step 1: Write the failing tests 1375 + 1376 + ```typescript 1377 + describe("createAdminRoutes — POST /admin/themes/:rkey/delete", () => { 1378 + beforeEach(() => { 1379 + vi.stubGlobal("fetch", mockFetch); 1380 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1381 + vi.resetModules(); 1382 + }); 1383 + 1384 + afterEach(() => { 1385 + vi.unstubAllGlobals(); 1386 + vi.unstubAllEnvs(); 1387 + mockFetch.mockReset(); 1388 + }); 1389 + 1390 + function mockResponse(body: unknown, ok = true, status = 200) { 1391 + return { ok, status, json: () => Promise.resolve(body) }; 1392 + } 1393 + 1394 + function setupAuthenticatedSession(permissions: string[]) { 1395 + mockFetch.mockResolvedValueOnce( 1396 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 1397 + ); 1398 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1399 + } 1400 + 1401 + async function loadAdminRoutes() { 1402 + const { createAdminRoutes } = await import("../admin.js"); 1403 + return createAdminRoutes("http://localhost:3000"); 1404 + } 1405 + 1406 + it("deletes theme and redirects to /admin/themes on success", async () => { 1407 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 1408 + mockFetch.mockResolvedValueOnce(mockResponse({ deleted: true }, true, 200)); 1409 + 1410 + const routes = await loadAdminRoutes(); 1411 + const res = await routes.request("/admin/themes/3lbltheme1aa/delete", { 1412 + method: "POST", 1413 + headers: { cookie: "atbb_session=token" }, 1414 + }); 1415 + 1416 + expect(res.status).toBe(302); 1417 + expect(res.headers.get("location")).toBe("/admin/themes"); 1418 + }); 1419 + 1420 + it("redirects with human-friendly error message on 409 conflict (theme is a default)", async () => { 1421 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 1422 + mockFetch.mockResolvedValueOnce( 1423 + mockResponse( 1424 + { error: "Cannot delete a theme that is currently set as a default" }, 1425 + false, 1426 + 409 1427 + ) 1428 + ); 1429 + 1430 + const routes = await loadAdminRoutes(); 1431 + const res = await routes.request("/admin/themes/3lbltheme1aa/delete", { 1432 + method: "POST", 1433 + headers: { cookie: "atbb_session=token" }, 1434 + }); 1435 + 1436 + expect(res.status).toBe(302); 1437 + const location = res.headers.get("location") ?? ""; 1438 + expect(location).toContain("/admin/themes?error="); 1439 + expect(decodeURIComponent(location)).toContain("Cannot delete"); 1440 + }); 1441 + 1442 + it("redirects with error on generic AppView failure", async () => { 1443 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 1444 + mockFetch.mockResolvedValueOnce( 1445 + mockResponse({ error: "Internal server error" }, false, 500) 1446 + ); 1447 + 1448 + const routes = await loadAdminRoutes(); 1449 + const res = await routes.request("/admin/themes/3lbltheme1aa/delete", { 1450 + method: "POST", 1451 + headers: { cookie: "atbb_session=token" }, 1452 + }); 1453 + 1454 + expect(res.status).toBe(302); 1455 + expect(res.headers.get("location")).toContain("/admin/themes?error="); 1456 + }); 1457 + }); 1458 + ``` 1459 + 1460 + ### Step 2: Run tests to verify they fail 1461 + 1462 + ```bash 1463 + pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "delete" 1464 + ``` 1465 + 1466 + ### Step 3: Implement 1467 + 1468 + Add after `POST /admin/themes/:rkey/duplicate`: 1469 + 1470 + ```typescript 1471 + app.post("/admin/themes/:rkey/delete", async (c) => { 1472 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1473 + if (!auth.authenticated) return c.redirect("/login"); 1474 + if (!canManageThemes(auth)) { 1475 + return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 1476 + } 1477 + 1478 + const cookie = c.req.header("cookie") ?? ""; 1479 + const themeRkey = c.req.param("rkey"); 1480 + 1481 + let apiRes: Response; 1482 + try { 1483 + apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, { 1484 + method: "DELETE", 1485 + headers: { Cookie: cookie }, 1486 + }); 1487 + } catch (error) { 1488 + if (isProgrammingError(error)) throw error; 1489 + logger.error("Network error deleting theme", { 1490 + operation: "POST /admin/themes/:rkey/delete", 1491 + themeRkey, 1492 + error: error instanceof Error ? error.message : String(error), 1493 + }); 1494 + return c.redirect( 1495 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1496 + 302 1497 + ); 1498 + } 1499 + 1500 + if (!apiRes.ok) { 1501 + const msg = await extractAppviewError(apiRes, "Failed to delete theme. Please try again."); 1502 + return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 1503 + } 1504 + 1505 + return c.redirect("/admin/themes", 302); 1506 + }); 1507 + ``` 1508 + 1509 + ### Step 4: Run tests to verify they pass 1510 + 1511 + ```bash 1512 + pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "delete" 1513 + ``` 1514 + 1515 + ### Step 5: Commit 1516 + 1517 + ```bash 1518 + git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx 1519 + git commit -m "$(cat <<'EOF' 1520 + feat(web): POST /admin/themes/:rkey/delete — proxy delete to AppView with 409 handling (ATB-58) 1521 + EOF 1522 + )" 1523 + ``` 1524 + 1525 + --- 1526 + 1527 + ## Task 8: Web — `POST /admin/theme-policy` 1528 + 1529 + The trickiest route: parses a checkbox (absent = false) and multi-value field (`availableThemes`). 1530 + 1531 + **Files:** 1532 + - Modify: `apps/web/src/routes/admin.tsx` 1533 + - Modify: `apps/web/src/routes/__tests__/admin.test.tsx` 1534 + 1535 + ### Step 1: Write the failing tests 1536 + 1537 + ```typescript 1538 + describe("createAdminRoutes — POST /admin/theme-policy", () => { 1539 + beforeEach(() => { 1540 + vi.stubGlobal("fetch", mockFetch); 1541 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1542 + vi.resetModules(); 1543 + }); 1544 + 1545 + afterEach(() => { 1546 + vi.unstubAllGlobals(); 1547 + vi.unstubAllEnvs(); 1548 + mockFetch.mockReset(); 1549 + }); 1550 + 1551 + function mockResponse(body: unknown, ok = true, status = 200) { 1552 + return { ok, status, json: () => Promise.resolve(body) }; 1553 + } 1554 + 1555 + function setupAuthenticatedSession(permissions: string[]) { 1556 + mockFetch.mockResolvedValueOnce( 1557 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 1558 + ); 1559 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1560 + } 1561 + 1562 + async function loadAdminRoutes() { 1563 + const { createAdminRoutes } = await import("../admin.js"); 1564 + return createAdminRoutes("http://localhost:3000"); 1565 + } 1566 + 1567 + it("saves policy and redirects to /admin/themes on success", async () => { 1568 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 1569 + mockFetch.mockResolvedValueOnce(mockResponse({ updated: true }, true, 200)); 1570 + 1571 + const routes = await loadAdminRoutes(); 1572 + const body = new URLSearchParams({ 1573 + defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1", 1574 + defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme2", 1575 + allowUserChoice: "on", 1576 + }); 1577 + body.append("availableThemes", "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1"); 1578 + body.append("availableThemes", "at://did:plc:forum/space.atbb.forum.theme/3lbltheme2"); 1579 + 1580 + const res = await routes.request("/admin/theme-policy", { 1581 + method: "POST", 1582 + headers: { 1583 + cookie: "atbb_session=token", 1584 + "content-type": "application/x-www-form-urlencoded", 1585 + }, 1586 + body: body.toString(), 1587 + }); 1588 + 1589 + expect(res.status).toBe(302); 1590 + expect(res.headers.get("location")).toBe("/admin/themes"); 1591 + 1592 + const apiCall = mockFetch.mock.calls[2]; 1593 + const sentBody = JSON.parse(apiCall[1].body); 1594 + expect(sentBody.allowUserChoice).toBe(true); 1595 + expect(sentBody.availableThemes).toHaveLength(2); 1596 + }); 1597 + 1598 + it("treats absent allowUserChoice checkbox as false", async () => { 1599 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 1600 + mockFetch.mockResolvedValueOnce(mockResponse({ updated: true }, true, 200)); 1601 + 1602 + const routes = await loadAdminRoutes(); 1603 + // No allowUserChoice field — checkbox was unchecked 1604 + const res = await routes.request("/admin/theme-policy", { 1605 + method: "POST", 1606 + headers: { 1607 + cookie: "atbb_session=token", 1608 + "content-type": "application/x-www-form-urlencoded", 1609 + }, 1610 + body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test", 1611 + }); 1612 + 1613 + expect(res.status).toBe(302); 1614 + const apiCall = mockFetch.mock.calls[2]; 1615 + const sentBody = JSON.parse(apiCall[1].body); 1616 + expect(sentBody.allowUserChoice).toBe(false); 1617 + }); 1618 + 1619 + it("sends empty availableThemes when no checkboxes are checked", async () => { 1620 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 1621 + mockFetch.mockResolvedValueOnce(mockResponse({ updated: true }, true, 200)); 1622 + 1623 + const routes = await loadAdminRoutes(); 1624 + const res = await routes.request("/admin/theme-policy", { 1625 + method: "POST", 1626 + headers: { 1627 + cookie: "atbb_session=token", 1628 + "content-type": "application/x-www-form-urlencoded", 1629 + }, 1630 + body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test&allowUserChoice=on", 1631 + }); 1632 + 1633 + expect(res.status).toBe(302); 1634 + const apiCall = mockFetch.mock.calls[2]; 1635 + const sentBody = JSON.parse(apiCall[1].body); 1636 + expect(sentBody.availableThemes).toEqual([]); 1637 + }); 1638 + 1639 + it("redirects with error on AppView failure", async () => { 1640 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 1641 + mockFetch.mockResolvedValueOnce( 1642 + mockResponse({ error: "Invalid theme URIs" }, false, 400) 1643 + ); 1644 + 1645 + const routes = await loadAdminRoutes(); 1646 + const res = await routes.request("/admin/theme-policy", { 1647 + method: "POST", 1648 + headers: { 1649 + cookie: "atbb_session=token", 1650 + "content-type": "application/x-www-form-urlencoded", 1651 + }, 1652 + body: "defaultLightThemeUri=bad&defaultDarkThemeUri=bad", 1653 + }); 1654 + 1655 + expect(res.status).toBe(302); 1656 + expect(res.headers.get("location")).toContain("/admin/themes?error="); 1657 + }); 1658 + }); 1659 + ``` 1660 + 1661 + ### Step 2: Run tests to verify they fail 1662 + 1663 + ```bash 1664 + pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "theme-policy" 1665 + ``` 1666 + 1667 + ### Step 3: Implement 1668 + 1669 + Add after `POST /admin/themes/:rkey/delete`: 1670 + 1671 + ```typescript 1672 + app.post("/admin/theme-policy", async (c) => { 1673 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1674 + if (!auth.authenticated) return c.redirect("/login"); 1675 + if (!canManageThemes(auth)) { 1676 + return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 1677 + } 1678 + 1679 + const cookie = c.req.header("cookie") ?? ""; 1680 + 1681 + let rawBody: Record<string, string | string[] | File | File[]>; 1682 + try { 1683 + rawBody = await c.req.parseBody({ all: true }); 1684 + } catch (error) { 1685 + if (isProgrammingError(error)) throw error; 1686 + return c.redirect( 1687 + `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 1688 + 302 1689 + ); 1690 + } 1691 + 1692 + const defaultLightThemeUri = 1693 + typeof rawBody.defaultLightThemeUri === "string" ? rawBody.defaultLightThemeUri : ""; 1694 + const defaultDarkThemeUri = 1695 + typeof rawBody.defaultDarkThemeUri === "string" ? rawBody.defaultDarkThemeUri : ""; 1696 + // Checkbox: present with value "on" when checked, absent when unchecked 1697 + const allowUserChoice = rawBody.allowUserChoice === "on"; 1698 + 1699 + // availableThemes may be a single string, an array, or absent 1700 + const rawAvailable = rawBody.availableThemes; 1701 + const availableThemes = 1702 + rawAvailable === undefined 1703 + ? [] 1704 + : Array.isArray(rawAvailable) 1705 + ? rawAvailable.filter((v): v is string => typeof v === "string") 1706 + : typeof rawAvailable === "string" 1707 + ? [rawAvailable] 1708 + : []; 1709 + 1710 + let apiRes: Response; 1711 + try { 1712 + apiRes = await fetch(`${appviewUrl}/api/admin/theme-policy`, { 1713 + method: "PUT", 1714 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1715 + body: JSON.stringify({ 1716 + defaultLightThemeUri, 1717 + defaultDarkThemeUri, 1718 + allowUserChoice, 1719 + availableThemes: availableThemes.map((uri) => ({ uri })), 1720 + }), 1721 + }); 1722 + } catch (error) { 1723 + if (isProgrammingError(error)) throw error; 1724 + logger.error("Network error updating theme policy", { 1725 + operation: "POST /admin/theme-policy", 1726 + error: error instanceof Error ? error.message : String(error), 1727 + }); 1728 + return c.redirect( 1729 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1730 + 302 1731 + ); 1732 + } 1733 + 1734 + if (!apiRes.ok) { 1735 + const msg = await extractAppviewError(apiRes, "Failed to update theme policy. Please try again."); 1736 + return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 1737 + } 1738 + 1739 + return c.redirect("/admin/themes", 302); 1740 + }); 1741 + ``` 1742 + 1743 + **Note on `availableThemes` format:** The `PUT /api/admin/theme-policy` AppView endpoint expects `availableThemes` as an array of objects `{ uri, cid }`. However, the web layer doesn't have the CIDs (they live in the DB). Check the AppView's `PUT /api/admin/theme-policy` handler to see if `cid` is required or if it can be omitted/empty. If CID is required, the GET page will need to pass CIDs as hidden inputs alongside the checkboxes. 1744 + 1745 + ### Step 4: Run tests to verify they pass 1746 + 1747 + ```bash 1748 + pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "theme-policy" 1749 + ``` 1750 + 1751 + ### Step 5: Commit 1752 + 1753 + ```bash 1754 + git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx 1755 + git commit -m "$(cat <<'EOF' 1756 + feat(web): POST /admin/theme-policy — update theme policy with availability and defaults (ATB-58) 1757 + EOF 1758 + )" 1759 + ``` 1760 + 1761 + --- 1762 + 1763 + ## Task 9: Final Verification + Linear Update 1764 + 1765 + ### Step 1: Run full test suite 1766 + 1767 + ```bash 1768 + PATH=/path/to/repo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm turbo test 1769 + ``` 1770 + 1771 + Expected: all tests pass. 1772 + 1773 + ### Step 2: Run lint:fix 1774 + 1775 + ```bash 1776 + PATH=/path/to/repo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm turbo lint:fix 1777 + ``` 1778 + 1779 + ### Step 3: Update Linear 1780 + 1781 + Mark ATB-58 status → "In Review" and add a comment documenting: 1782 + - New AppView endpoints: `GET /api/admin/themes`, `POST /api/admin/themes/:rkey/duplicate` 1783 + - New web routes: `GET /admin/themes` + 4 POST routes 1784 + - Session: `canManageThemes` added 1785 + - Known gap: Edit button renders disabled (links to ATB-59 editor not yet built) 1786 + - Known gap: CID handling in theme-policy — verify AppView accepts `{ uri }` without CID 1787 + 1788 + ### Step 4: Check `availableThemes` CID requirement 1789 + 1790 + Read `apps/appview/src/routes/admin.ts` around the `PUT /theme-policy` handler (line ~1257) to check whether CID is required in `availableThemes`. If it is: 1791 + 1. In the GET /admin/themes page JSX, add hidden inputs alongside each availability checkbox: 1792 + ```tsx 1793 + <input type="hidden" form="policy-form" name={`cid_${theme.uri}`} value={/* need CID */} /> 1794 + ``` 1795 + But the AdminThemeEntry doesn't include CID — it would need to come from the policy's `availableThemes` array for already-available themes. This might require a design adjustment. 1796 + 1797 + 2. Alternatively: the AppView can look up CIDs itself when writing the policy (it has the DB). In that case, the web layer can send just `{ uri }` and the AppView fills in the CID. 1798 + 1799 + Resolve this before declaring done. 1800 + 1801 + --- 1802 + 1803 + ## Known Limitations (for follow-up issues) 1804 + 1805 + - **Edit button disabled:** `/admin/themes/:rkey` editor page is ATB-59 (not yet built). The Edit button renders as a non-functional span with `aria-disabled="true"`. 1806 + - **Missing presets:** Clean Light, Clean Dark, Classic BB presets not yet created. The Create form offers only Neobrutal Light, Neobrutal Dark, Blank. 1807 + - **Bruno collection:** Update `bruno/` with the new `GET /api/admin/themes` and `POST /api/admin/themes/:rkey/duplicate` endpoints in the same branch before requesting review.
+159
docs/plans/2026-03-02-atb-58-design.md
··· 1 + # ATB-58: Admin Theme List Page — Design 2 + 3 + **Status:** Approved 4 + **Linear:** ATB-58 5 + **Depends on:** ATB-55 (read endpoints), ATB-57 (write endpoints — complete) 6 + 7 + --- 8 + 9 + ## Overview 10 + 11 + Admin page at `GET /admin/themes` for viewing, creating, duplicating, and deleting themes, and managing the theme policy. Follows established admin panel patterns: permission-gated page, `<details>` for inline create form, `<dialog>` for delete confirmations, POST-redirect-GET flow. 12 + 13 + --- 14 + 15 + ## Section 1: New AppView Endpoints 16 + 17 + Two new endpoints added to `apps/appview/src/routes/admin.ts`: 18 + 19 + ### `GET /api/admin/themes` 20 + 21 + Returns all themes for the forum DID — **no policy filtering** — with full token data for color swatch rendering. Requires `manageThemes` permission. 22 + 23 + ``` 24 + Response: { themes: [{ id, uri, name, colorScheme, tokens, cssOverrides, fontUrls, createdAt, indexedAt }] } 25 + ``` 26 + 27 + Middleware: `requireAuth(ctx)` → `requirePermission(ctx, "space.atbb.permission.manageThemes")` → handler. 28 + 29 + ### `POST /api/admin/themes/:rkey/duplicate` 30 + 31 + Clones a theme: fetches source from DB by rkey, generates a fresh TID via `TID.nextStr()`, writes a new PDS record with name + `" (Copy)"`. Returns `{ uri, rkey, name }` on 201. Returns 404 if source not found. 32 + 33 + Same middleware pattern as above. 34 + 35 + --- 36 + 37 + ## Section 2: Session Layer + Admin Landing Page 38 + 39 + ### `apps/web/src/lib/session.ts` 40 + 41 + 1. Add `"space.atbb.permission.manageThemes"` to the `ADMIN_PERMISSIONS` array. 42 + 2. Add `canManageThemes(auth: WebSessionWithPermissions): boolean` — same pattern as `canManageRoles`. 43 + 44 + ### `apps/web/src/routes/admin.tsx` — `GET /admin` 45 + 46 + Add Themes card to the landing page grid, gated by `canManageThemes(auth)`: 47 + 48 + ``` 49 + 🎨 Themes 50 + Customize forum appearance and color schemes 51 + → /admin/themes 52 + ``` 53 + 54 + --- 55 + 56 + ## Section 3: `GET /admin/themes` Page Layout 57 + 58 + Data fetched server-side before render: 59 + - `GET /api/admin/themes` — all themes with full token data 60 + - `GET /api/theme-policy` — current policy (defaults, availableThemes URIs, allowUserChoice) 61 + 62 + Page sections top to bottom: 63 + 64 + ### Error Banner 65 + ```tsx 66 + {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 67 + ``` 68 + Sourced from `?error=` query param, same as the structure page. 69 + 70 + ### Theme Cards List 71 + 72 + One card per theme. Each card contains: 73 + 74 + - Theme name + `colorScheme` badge (`light` / `dark`) 75 + - Color swatch row: 5 small `<span>` squares with inline `style="background: {token}"` for tokens `color-bg`, `color-surface`, `color-primary`, `color-secondary`, `color-border` 76 + - "Edit" link → `/admin/themes/:rkey` (ATB-59, not yet implemented — renders as disabled link) 77 + - "Duplicate" `<form method="post" action="/admin/themes/:rkey/duplicate">` with submit button 78 + - "Delete" button calling `showModal()` on a per-card `<dialog>` containing `<form method="post" action="/admin/themes/:rkey/delete">` 79 + - Availability checkbox: `<input type="checkbox" form="policy-form" name="availableThemes" value="{uri}" checked={isAvailable}>` — associated with the policy form via HTML `form` attribute, not nesting 80 + 81 + ### Policy Section 82 + 83 + ```html 84 + <form id="policy-form" method="post" action="/admin/theme-policy"> 85 + <!-- Default Light Theme --> 86 + <select name="defaultLightThemeUri"> ... light-scheme themes ... </select> 87 + 88 + <!-- Default Dark Theme --> 89 + <select name="defaultDarkThemeUri"> ... dark-scheme themes ... </select> 90 + 91 + <!-- Allow User Choice --> 92 + <input type="checkbox" name="allowUserChoice" checked={policy.allowUserChoice}> 93 + 94 + <button type="submit">Save Policy</button> 95 + </form> 96 + ``` 97 + 98 + ### Create Theme `<details>` 99 + 100 + Collapsible form at the bottom: 101 + - Name (text input, required) 102 + - Color Scheme (select: `light` / `dark`) 103 + - Start from preset (select: `Neobrutal Light`, `Neobrutal Dark`, `Blank`) 104 + - Submit → `POST /admin/themes` 105 + 106 + Note: Only 2 presets exist (`neobrutal-light.json`, `neobrutal-dark.json`). Additional presets (Clean Light, Clean Dark, Classic BB) are a follow-up. 107 + 108 + --- 109 + 110 + ## Section 4: Web POST Routes 111 + 112 + All in `apps/web/src/routes/admin.tsx`. All use parse → validate → proxy AppView API → redirect pattern. 113 + 114 + ### `POST /admin/themes` (create) 115 + - Parses `name`, `colorScheme`, `preset` from form body 116 + - Loads preset tokens from `apps/web/src/styles/presets/` JSON files, or `{}` for Blank 117 + - POSTs to `POST /api/admin/themes` with `{ name, colorScheme, tokens }` 118 + - Success → redirect `/admin/themes` 119 + 120 + ### `POST /admin/themes/:rkey/duplicate` 121 + - No body parsing needed 122 + - POSTs to `POST /api/admin/themes/:rkey/duplicate` 123 + - Success → redirect `/admin/themes` 124 + 125 + ### `POST /admin/themes/:rkey/delete` 126 + - No body parsing needed 127 + - DELETEs to `DELETE /api/admin/themes/:rkey` 128 + - 409 response → redirect `/admin/themes?error=Cannot delete a theme that is currently set as a default` 129 + - Success → redirect `/admin/themes` 130 + 131 + ### `POST /admin/theme-policy` 132 + - Parses `defaultLightThemeUri`, `defaultDarkThemeUri`, `allowUserChoice` (absent when unchecked → `false`), `availableThemes` (array via `parseBody`) 133 + - PUTs to `PUT /api/admin/theme-policy` 134 + - Success → redirect `/admin/themes` 135 + 136 + --- 137 + 138 + ## Section 5: Tests 139 + 140 + ### AppView (`apps/appview/src/routes/__tests__/themes.test.ts`) 141 + - `GET /api/admin/themes`: returns all themes unfiltered by policy; 401 unauthenticated; 403 insufficient permission 142 + - `POST /api/admin/themes/:rkey/duplicate`: creates copy with "(Copy)" suffix and new rkey; 404 for unknown rkey; 401/403 for auth 143 + 144 + ### Web (`apps/web/src/routes/__tests__/admin.test.tsx`) 145 + - `GET /admin/themes`: renders theme cards with color swatches; shows error banner from `?error=`; 403 for missing `manageThemes` permission; redirects to login if unauthenticated 146 + - `POST /admin/themes`: creates theme with preset tokens loaded from JSON; redirects on success; redirects with error on AppView failure 147 + - `POST /admin/themes/:rkey/duplicate`: redirects on success; redirects with error on AppView failure 148 + - `POST /admin/themes/:rkey/delete`: redirects on success; redirects with human-friendly error message on 409 conflict 149 + - `POST /admin/theme-policy`: saves policy; correctly handles absent `allowUserChoice` checkbox as `false`; redirects on success 150 + 151 + --- 152 + 153 + ## Key Decisions 154 + 155 + - **Form attribute pattern:** Availability checkboxes on theme cards use `<input form="policy-form">` to associate with the policy form without HTML nesting. Cards can contain their own `<form>` elements for delete/duplicate. 156 + - **Presets scoped to 2 + Blank:** Only existing preset JSONs used. Additional presets deferred. 157 + - **Edit link disabled:** `/admin/themes/:rkey` editor (ATB-59) not implemented yet — edit button renders as a placeholder. 158 + - **Admin-only theme list:** `GET /api/admin/themes` is separate from the public `GET /api/themes` (which filters by policy). Admins see all themes including drafts not yet in the policy. 159 + - **Duplicate in appview:** Clone logic lives in a dedicated `POST /api/admin/themes/:rkey/duplicate` appview endpoint — keeps all PDS write logic server-side and consistent with existing patterns.
+165
docs/plans/2026-03-02-css-token-extraction-design.md
··· 1 + # ATB-52: CSS Token Extraction — Design 2 + 3 + **Status:** Approved, ready for implementation 4 + **Linear:** ATB-52 5 + **Date:** 2026-03-02 6 + 7 + --- 8 + 9 + ## Context 10 + 11 + The web UI uses a neobrutal aesthetic with a CSS custom property token system. Most of `theme.css` already references `var(--token)` exclusively. Two sections were added separately (moderation UI, structure management UI) and were never aligned with the token schema. This design covers the full extraction. 12 + 13 + `tokensToCss()` and `neobrutal-light.ts` already exist. The work is: fix the remaining hardcoded values, add one missing token, convert presets to JSON, and add the dark preset. 14 + 15 + --- 16 + 17 + ## Audit: Hardcoded Values Remaining 18 + 19 + ### Moderation UI (`theme.css` lines 751–821) 20 + 21 + | Hardcoded value | Replace with | 22 + |----------------|--------------| 23 + | `var(--space-2)` | `var(--space-sm)` (8px = 0.5rem) | 24 + | `var(--space-4)` | `var(--space-md)` (16px = 1rem) | 25 + | `var(--space-6)` | `var(--space-lg)` (24px = 1.5rem) | 26 + | `1px solid var(--color-border)` | `var(--border-width) solid var(--color-border)` | 27 + | `2px solid currentColor` | `var(--border-width) solid currentColor` | 28 + | `border-radius: 0` | `var(--radius)` | 29 + | `font-weight: 700` | `var(--font-weight-bold)` | 30 + | `0.25rem 0.6rem` (mod-btn padding) | `var(--space-xs) var(--space-sm)` | 31 + | `0.75rem` (mod-btn font-size) | `var(--font-size-xs)` ← new token | 32 + | `1.25rem` (dialog title font-size) | `var(--font-size-lg)` (20px = 1.25rem) | 33 + | `6px 6px 0 var(--color-shadow)` | `var(--card-shadow)` | 34 + | `color: #fff` (hover text) | `var(--color-surface)` | 35 + | `var(--color-danger, #d00)` | `var(--color-danger)` (remove fallback) | 36 + | `var(--color-text-muted, #666)` | `var(--color-text-muted)` (remove fallback) | 37 + | `3px solid var(--color-border)` | `var(--border-width) solid var(--color-border)` | 38 + 39 + ### Structure UI (`theme.css` lines 1003–1154) 40 + 41 + | Hardcoded value | Replace with | 42 + |----------------|--------------| 43 + | `var(--space-6, 1.5rem)` | `var(--space-lg)` (remove fallback) | 44 + | `var(--radius, 0.5rem)` | `var(--radius)` (remove fallback) | 45 + | `var(--radius, 0.375rem)` | `var(--radius)` (remove fallback) | 46 + | `var(--font-size-xl, 2rem)` | `var(--font-size-xl)` (remove fallback) | 47 + 48 + --- 49 + 50 + ## Token Schema Addition 51 + 52 + One new token added to complete the type scale: 53 + 54 + | Token | neobrutal-light | neobrutal-dark | Description | 55 + |-------|----------------|----------------|-------------| 56 + | `font-size-xs` | `12px` | `12px` | Extra-small text (mod buttons, badges) | 57 + 58 + --- 59 + 60 + ## Preset Files 61 + 62 + ### Format 63 + 64 + Convert from TypeScript to JSON. `resolveJsonModule: true` is already set in `tsconfig.base.json` — no config changes needed. Import in `base.tsx` changes to: 65 + 66 + ```typescript 67 + import neobrutalLight from "../styles/presets/neobrutal-light.json" assert { type: "json" }; 68 + ``` 69 + 70 + Or, since `moduleResolution: bundler` is set, the assert clause may not be required — verify during implementation. 71 + 72 + ### `neobrutal-light.json` (converted from existing TS, adds `font-size-xs`) 73 + 74 + ```json 75 + { 76 + "color-bg": "#f5f0e8", 77 + "color-surface": "#ffffff", 78 + "color-text": "#1a1a1a", 79 + "color-text-muted": "#555555", 80 + "color-primary": "#ff5c00", 81 + "color-primary-hover": "#e04f00", 82 + "color-secondary": "#3a86ff", 83 + "color-border": "#1a1a1a", 84 + "color-shadow": "#1a1a1a", 85 + "color-success": "#2ec44a", 86 + "color-warning": "#ffbe0b", 87 + "color-danger": "#ff006e", 88 + "color-code-bg": "#1a1a1a", 89 + "color-code-text": "#f5f0e8", 90 + "font-body": "'Space Grotesk', system-ui, sans-serif", 91 + "font-heading": "'Space Grotesk', system-ui, sans-serif", 92 + "font-mono": "'JetBrains Mono', ui-monospace, monospace", 93 + "font-size-base": "16px", 94 + "font-size-sm": "14px", 95 + "font-size-xs": "12px", 96 + "font-size-lg": "20px", 97 + "font-size-xl": "28px", 98 + "font-size-2xl": "36px", 99 + "font-weight-normal": "400", 100 + "font-weight-bold": "700", 101 + "line-height-body": "1.6", 102 + "line-height-heading": "1.2", 103 + "space-xs": "4px", 104 + "space-sm": "8px", 105 + "space-md": "16px", 106 + "space-lg": "24px", 107 + "space-xl": "40px", 108 + "radius": "0px", 109 + "border-width": "2px", 110 + "shadow-offset": "2px", 111 + "content-width": "100%", 112 + "button-radius": "0px", 113 + "button-shadow": "2px 2px 0 var(--color-shadow)", 114 + "card-radius": "0px", 115 + "card-shadow": "4px 4px 0 var(--color-shadow)", 116 + "btn-press-hover": "1px", 117 + "btn-press-active": "2px", 118 + "input-radius": "0px", 119 + "input-border": "2px solid var(--color-border)", 120 + "nav-height": "64px" 121 + } 122 + ``` 123 + 124 + ### `neobrutal-dark.json` (new) 125 + 126 + Same structural/typography/spacing tokens. Color tokens that differ: 127 + 128 + | Token | Value | 129 + |-------|-------| 130 + | `color-bg` | `#1a1a1a` | 131 + | `color-surface` | `#2d2d2d` | 132 + | `color-text` | `#f5f0e8` | 133 + | `color-text-muted` | `#a0a0a0` | 134 + | `color-primary-hover` | `#ff7a2a` (lightened for dark bg) | 135 + | `color-border` | `#f5f0e8` (inverted from light) | 136 + | `color-shadow` | `#000000` | 137 + | `color-code-bg` | `#111111` | 138 + 139 + All other tokens (primary, secondary, success, warning, danger, all typography, all spacing, all component) are identical to neobrutal-light. 140 + 141 + --- 142 + 143 + ## File Changes 144 + 145 + | File | Action | 146 + |------|--------| 147 + | `public/static/css/theme.css` | Fix ~15 hardcoded values in mod UI + structure UI | 148 + | `src/styles/presets/neobrutal-light.ts` | Delete | 149 + | `src/styles/presets/neobrutal-light.json` | Create (converted from TS, adds `font-size-xs`) | 150 + | `src/styles/presets/neobrutal-dark.json` | Create (dark color tokens, same structure) | 151 + | `src/layouts/base.tsx` | Update import to JSON | 152 + | `src/lib/theme.ts` | No changes | 153 + | `src/lib/__tests__/theme.test.ts` | No changes | 154 + 155 + --- 156 + 157 + ## Acceptance Criteria (from ATB-52) 158 + 159 + - [ ] `theme.css` contains zero hardcoded color values, font stacks, spacing values, or font sizes — all use `var(--token)` 160 + - [ ] No fallback values in `var()` calls (fallbacks are hardcoded values in disguise) 161 + - [ ] `tokensToCss()` utility exists and is tested (already satisfied) 162 + - [ ] `neobrutal-light.json` and `neobrutal-dark.json` ship with complete token sets 163 + - [ ] `--font-size-xs` added to both presets 164 + - [ ] `base.tsx` imports from JSON and the forum renders identically to before 165 + - [ ] All existing views render correctly after the refactor
+197
docs/plans/2026-03-02-theme-api-design.md
··· 1 + # ATB-55: Theme Read API Endpoints — Design 2 + 3 + **Date:** 2026-03-02 4 + **Status:** Approved 5 + **Linear:** ATB-55 6 + **Depends on:** ATB-51 (theme + themePolicy lexicons — already shipped) 7 + 8 + --- 9 + 10 + ## Summary 11 + 12 + Add database tables, firehose indexers, and read-only REST endpoints for theme and 13 + theme policy data. The web UI (and future mobile clients) will consume these to 14 + resolve which CSS tokens to render per request. 15 + 16 + --- 17 + 18 + ## Database Schema 19 + 20 + Three new tables added to both `packages/db/src/schema.ts` (Postgres) and 21 + `packages/db/src/schema.sqlite.ts` (SQLite). All follow the existing 22 + `(did, rkey, cid, indexed_at)` pattern. 23 + 24 + ### `themes` 25 + 26 + | Column | Postgres | SQLite | Notes | 27 + |---|---|---|---| 28 + | `id` | `bigserial PK` | `integer PK autoIncrement` | | 29 + | `did` | `text NOT NULL` | `text NOT NULL` | Forum DID | 30 + | `rkey` | `text NOT NULL` | `text NOT NULL` | TID key | 31 + | `cid` | `text NOT NULL` | `text NOT NULL` | | 32 + | `name` | `text NOT NULL` | `text NOT NULL` | | 33 + | `colorScheme` | `text NOT NULL` | `text NOT NULL` | `"light"` or `"dark"` | 34 + | `tokens` | `jsonb NOT NULL` | `text NOT NULL` | SQLite: JSON string | 35 + | `cssOverrides` | `text` | `text` | Optional raw CSS | 36 + | `fontUrls` | `text[] ` | `text` | SQLite: JSON string array | 37 + | `createdAt` | `timestamp` | `integer (timestamp)` | | 38 + | `indexedAt` | `timestamp` | `integer (timestamp)` | | 39 + 40 + Indexes: `UNIQUE(did, rkey)` 41 + 42 + ### `theme_policies` 43 + 44 + Singleton per forum (rkey is always `"self"`). 45 + 46 + | Column | Postgres | SQLite | Notes | 47 + |---|---|---|---| 48 + | `id` | `bigserial PK` | `integer PK autoIncrement` | | 49 + | `did` | `text NOT NULL` | `text NOT NULL` | Forum DID | 50 + | `rkey` | `text NOT NULL` | `text NOT NULL` | Always `"self"` | 51 + | `cid` | `text NOT NULL` | `text NOT NULL` | | 52 + | `defaultLightThemeUri` | `text NOT NULL` | `text NOT NULL` | AT-URI | 53 + | `defaultDarkThemeUri` | `text NOT NULL` | `text NOT NULL` | AT-URI | 54 + | `allowUserChoice` | `boolean NOT NULL` | `integer (boolean)` | | 55 + | `indexedAt` | `timestamp` | `integer (timestamp)` | | 56 + 57 + Indexes: `UNIQUE(did, rkey)` 58 + 59 + ### `theme_policy_available_themes` 60 + 61 + Normalized join table for the `availableThemes` array from themePolicy records. 62 + Enables SQL-level filtering in `GET /api/themes` without application-layer iteration. 63 + 64 + | Column | Postgres | SQLite | Notes | 65 + |---|---|---|---| 66 + | `policyId` | `bigint FK → theme_policies.id ON DELETE CASCADE` | `integer FK` | | 67 + | `themeUri` | `text NOT NULL` | `text NOT NULL` | AT-URI of the theme | 68 + | `themeCid` | `text NOT NULL` | `text NOT NULL` | CID for integrity | 69 + 70 + Primary key: `(policyId, themeUri)` 71 + 72 + --- 73 + 74 + ## Firehose Indexer 75 + 76 + Two new `CollectionConfig` entries in `apps/appview/src/lib/indexer.ts`, following 77 + the pattern established by `categoryConfig`, `roleConfig`, etc. 78 + 79 + ### `space.atbb.forum.theme` 80 + 81 + - `toInsertValues`: maps `name`, `colorScheme`, `tokens` (JSON.stringify for SQLite, 82 + raw object for Postgres), `cssOverrides`, `fontUrls` (array for Postgres, 83 + JSON.stringify for SQLite), `createdAt`, `indexedAt` 84 + - `toUpdateValues`: same fields minus `did` / `rkey` / `createdAt` 85 + - No `afterUpsert` needed 86 + 87 + ### `space.atbb.forum.themePolicy` 88 + 89 + - `toInsertValues`: maps `defaultLightTheme.theme.uri`, `defaultDarkTheme.theme.uri`, 90 + `allowUserChoice`, `indexedAt` 91 + - `afterUpsert`: atomically replaces `theme_policy_available_themes` rows for this 92 + policy — DELETE existing rows by `policyId`, then INSERT one row per entry in 93 + `record.availableThemes`. Same pattern as `roleConfig.afterUpsert` for permissions. 94 + 95 + Both collections registered in `firehose.ts` `createHandlerRegistry()`. 96 + 97 + --- 98 + 99 + ## API Endpoints 100 + 101 + New file: `apps/appview/src/routes/themes.ts` 102 + Factory function: `createThemesRoutes(ctx: AppContext)` 103 + Registered in `routes/index.ts` as `.route("/themes", createThemesRoutes(ctx))` 104 + 105 + ### `GET /api/themes` 106 + 107 + Returns themes filtered to those in `themePolicy.availableThemes` via SQL join. 108 + Returns `{ themes: [] }` when no policy exists (no 404). 109 + 110 + Query: 111 + ```sql 112 + SELECT t.* FROM themes t 113 + INNER JOIN theme_policy_available_themes tpa 114 + ON tpa.theme_uri = ('at://' || t.did || '/space.atbb.forum.theme/' || t.rkey) 115 + INNER JOIN theme_policies tp ON tp.id = tpa.policy_id 116 + ``` 117 + 118 + Response: `{ themes: [{ id, uri, name, colorScheme, indexedAt }] }` 119 + (Token summary only — full tokens are in the single-theme endpoint.) 120 + 121 + Error codes: 500 with structured logging for DB errors. 122 + 123 + ### `GET /api/themes/:rkey` 124 + 125 + Returns full theme data for a single theme identified by its rkey. 126 + The Forum DID comes from `ctx.config.forumDid`. 127 + 128 + Validation: 400 for empty/missing rkey (rkeys are TIDs, not BigInts — use string 129 + validation, not `parseBigIntParam`). 130 + 131 + Response: `{ id, uri, name, colorScheme, tokens, cssOverrides, fontUrls, createdAt, indexedAt }` 132 + 133 + Error codes: 400 (invalid rkey), 404 (theme not found), 500 (DB error). 134 + 135 + ### `GET /api/theme-policy` 136 + 137 + Returns the forum's singleton themePolicy record with its `availableThemes` list. 138 + Returns 404 when no policy exists. 139 + 140 + Queries `theme_policies` then assembles `availableThemes` from 141 + `theme_policy_available_themes` join. 142 + 143 + Response: 144 + ```json 145 + { 146 + "defaultLightThemeUri": "at://...", 147 + "defaultDarkThemeUri": "at://...", 148 + "allowUserChoice": true, 149 + "availableThemes": [{ "uri": "at://...", "cid": "..." }] 150 + } 151 + ``` 152 + 153 + Error codes: 404 (no policy), 500 (DB error). 154 + 155 + --- 156 + 157 + ## Tests 158 + 159 + File: `apps/appview/src/routes/__tests__/themes.test.ts` 160 + 161 + ### Happy path 162 + 163 + - `GET /api/themes` returns only themes listed in `availableThemes` (not all themes in DB) 164 + - `GET /api/themes/:rkey` returns full token set for a known theme 165 + - `GET /api/theme-policy` returns correct `allowUserChoice` and `availableThemes` 166 + 167 + ### Error / edge cases 168 + 169 + - `GET /api/themes` returns `{ themes: [] }` when no policy exists 170 + - `GET /api/themes/:rkey` returns 404 for unknown rkey 171 + - `GET /api/themes/:rkey` returns 400 for empty rkey 172 + - `GET /api/theme-policy` returns 404 when no policy exists 173 + - `GET /api/themes` does **not** include a theme that exists in DB but is absent from `availableThemes` 174 + 175 + --- 176 + 177 + ## Bruno Collection 178 + 179 + New folder: `bruno/AppView API/Themes/` 180 + 181 + | File | Method | URL | 182 + |---|---|---| 183 + | `List Available Themes.bru` | GET | `{{appview_url}}/api/themes` | 184 + | `Get Theme.bru` | GET | `{{appview_url}}/api/themes/{{theme_rkey}}` | 185 + | `Get Theme Policy.bru` | GET | `{{appview_url}}/api/theme-policy` | 186 + 187 + Each file documents all HTTP status codes the endpoint can return and uses 188 + environment variables for all URLs and test data. 189 + 190 + --- 191 + 192 + ## Out of Scope (ATB-55) 193 + 194 + - Write endpoints (`POST /api/themes`, `PUT /api/theme-policy`, etc.) — separate ticket 195 + - User theme preference (`PATCH /api/membership/theme`) — separate ticket 196 + - CSS sanitization for `cssOverrides` — required before admin write endpoints ship 197 + - Web UI theme resolution and injection into `BaseLayout`
+1 -1
docs/plans/2026-03-03-refactoring-opportunities.md
··· 10 10 11 11 ## 1. Break Up `routes/helpers.ts` (675 lines) 12 12 13 - **Status: In progress** 13 + **Status: Complete** (shipped in #89) 14 14 15 15 The monolithic helpers file mixes serialization, validation, DB queries, and type exports. Split into focused modules under `routes/helpers/`: 16 16
+1417
docs/plans/complete/2026-03-02-atb-57-theme-write-api.md
··· 1 + # ATB-57: Theme Write API Endpoints — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add admin write endpoints for creating, updating, and deleting themes, and managing the theme policy singleton. 6 + 7 + **Architecture:** All four endpoints live in `apps/appview/src/routes/admin.ts` (same file as category/board write endpoints), gated by a new `space.atbb.permission.manageThemes` permission. They follow the established PDS-first pattern: validate → get ForumAgent → `putRecord`/`deleteRecord` on Forum DID's PDS → return `{ uri, cid }`. The firehose indexer handles DB rows asynchronously. 8 + 9 + **Tech Stack:** Hono, Drizzle ORM (postgres.js), AT Protocol (`com.atproto.repo.putRecord`), `@atproto/common-web` TID generator, Vitest 10 + 11 + --- 12 + 13 + ## Context & Patterns to Know 14 + 15 + **The PDS-first write pattern** (established by categories/boards in `admin.ts`): 16 + 1. Parse + validate request body (`safeParseJsonBody`) 17 + 2. For PUT/DELETE: look up existing DB row first — 404 if missing 18 + 3. `getForumAgentOrError` — returns 503 if ForumAgent not configured 19 + 4. Call `agent.com.atproto.repo.putRecord` / `deleteRecord` 20 + 5. Return `{ uri, cid }` from result 21 + 22 + **Test scaffolding** (from `admin.test.ts`): Auth and permissions middleware are **mocked at module level** — `requireAuth` always passes the `mockUser` object, `requirePermission` always calls `next()`. Tests set `ctx.forumAgent` to a mock with `mockPutRecord` / `mockDeleteRecord`. Tests use `describe.sequential` because they share a single `TestContext`. 23 + 24 + **Running tests:** 25 + ```bash 26 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview test 27 + # or for a single file: 28 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 29 + ``` 30 + 31 + Replace `/path/to/` with the absolute path to the repo root (check with `pwd`). 32 + 33 + **Imports needed in `admin.ts`** — you'll need to add: 34 + - `themes, themePolicies` to the `@atbb/db` import 35 + - `or` to the `drizzle-orm` import (it's not there yet) 36 + 37 + --- 38 + 39 + ## Task 1: Add `manageThemes` permission to seed-roles 40 + 41 + **Files:** 42 + - Modify: `apps/appview/src/lib/seed-roles.ts` 43 + 44 + No test needed (it's runtime seed data, not business logic). The Admin role needs `space.atbb.permission.manageThemes` added. 45 + 46 + **Step 1: Add permission to Admin role** 47 + 48 + In `seed-roles.ts`, find the `"Admin"` entry in `DEFAULT_ROLES` and add `"space.atbb.permission.manageThemes"` to its `permissions` array: 49 + 50 + ```typescript 51 + { 52 + name: "Admin", 53 + description: "Can manage forum structure and users", 54 + permissions: [ 55 + "space.atbb.permission.manageCategories", 56 + "space.atbb.permission.manageRoles", 57 + "space.atbb.permission.manageMembers", 58 + "space.atbb.permission.manageThemes", // ← add this line 59 + "space.atbb.permission.moderatePosts", 60 + "space.atbb.permission.banUsers", 61 + "space.atbb.permission.pinTopics", 62 + "space.atbb.permission.lockTopics", 63 + "space.atbb.permission.createTopics", 64 + "space.atbb.permission.createPosts", 65 + ], 66 + priority: 10, 67 + critical: true, 68 + }, 69 + ``` 70 + 71 + **Step 2: Verify existing tests still pass** 72 + 73 + ```bash 74 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run 75 + ``` 76 + 77 + Expected: all existing tests pass. 78 + 79 + **Step 3: Commit** 80 + 81 + ```bash 82 + git add apps/appview/src/lib/seed-roles.ts 83 + git commit -m "feat(appview): add manageThemes permission to Admin role (ATB-57)" 84 + ``` 85 + 86 + --- 87 + 88 + ## Task 2: Write failing tests for `POST /api/admin/themes` 89 + 90 + **Files:** 91 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 92 + 93 + **Step 1: Add import for `themes` table** 94 + 95 + At the top of `admin.test.ts`, find the `@atbb/db` import and add `themes`: 96 + 97 + ```typescript 98 + import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions, themes } from "@atbb/db"; 99 + ``` 100 + 101 + **Step 2: Add the test describe block** 102 + 103 + At the bottom of the file (inside `describe.sequential("Admin Routes", ...)`, before the closing brace), add: 104 + 105 + ```typescript 106 + describe("POST /api/admin/themes", () => { 107 + it("creates theme and returns 201 with uri and cid", async () => { 108 + const res = await app.request("/api/admin/themes", { 109 + method: "POST", 110 + headers: { "Content-Type": "application/json" }, 111 + body: JSON.stringify({ 112 + name: "Neobrutal Light", 113 + colorScheme: "light", 114 + tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 115 + }), 116 + }); 117 + expect(res.status).toBe(201); 118 + const body = await res.json(); 119 + expect(body.uri).toBeDefined(); 120 + expect(body.cid).toBeDefined(); 121 + expect(mockPutRecord).toHaveBeenCalledOnce(); 122 + }); 123 + 124 + it("includes cssOverrides and fontUrls when provided", async () => { 125 + const res = await app.request("/api/admin/themes", { 126 + method: "POST", 127 + headers: { "Content-Type": "application/json" }, 128 + body: JSON.stringify({ 129 + name: "Custom Theme", 130 + colorScheme: "dark", 131 + tokens: { "color-bg": "#1a1a1a" }, 132 + cssOverrides: ".card { border-radius: 4px; }", 133 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"], 134 + }), 135 + }); 136 + expect(res.status).toBe(201); 137 + const call = mockPutRecord.mock.calls[0][0]; 138 + expect(call.record.cssOverrides).toBe(".card { border-radius: 4px; }"); 139 + expect(call.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 140 + }); 141 + 142 + it("returns 400 when name is missing", async () => { 143 + const res = await app.request("/api/admin/themes", { 144 + method: "POST", 145 + headers: { "Content-Type": "application/json" }, 146 + body: JSON.stringify({ colorScheme: "light", tokens: {} }), 147 + }); 148 + expect(res.status).toBe(400); 149 + const body = await res.json(); 150 + expect(body.error).toMatch(/name/i); 151 + }); 152 + 153 + it("returns 400 when name is empty string", async () => { 154 + const res = await app.request("/api/admin/themes", { 155 + method: "POST", 156 + headers: { "Content-Type": "application/json" }, 157 + body: JSON.stringify({ name: " ", colorScheme: "light", tokens: {} }), 158 + }); 159 + expect(res.status).toBe(400); 160 + }); 161 + 162 + it("returns 400 when colorScheme is invalid", async () => { 163 + const res = await app.request("/api/admin/themes", { 164 + method: "POST", 165 + headers: { "Content-Type": "application/json" }, 166 + body: JSON.stringify({ name: "Test", colorScheme: "purple", tokens: {} }), 167 + }); 168 + expect(res.status).toBe(400); 169 + const body = await res.json(); 170 + expect(body.error).toMatch(/colorScheme/i); 171 + }); 172 + 173 + it("returns 400 when colorScheme is missing", async () => { 174 + const res = await app.request("/api/admin/themes", { 175 + method: "POST", 176 + headers: { "Content-Type": "application/json" }, 177 + body: JSON.stringify({ name: "Test", tokens: {} }), 178 + }); 179 + expect(res.status).toBe(400); 180 + }); 181 + 182 + it("returns 400 when tokens is missing", async () => { 183 + const res = await app.request("/api/admin/themes", { 184 + method: "POST", 185 + headers: { "Content-Type": "application/json" }, 186 + body: JSON.stringify({ name: "Test", colorScheme: "light" }), 187 + }); 188 + expect(res.status).toBe(400); 189 + const body = await res.json(); 190 + expect(body.error).toMatch(/tokens/i); 191 + }); 192 + 193 + it("returns 400 when tokens is an array (not an object)", async () => { 194 + const res = await app.request("/api/admin/themes", { 195 + method: "POST", 196 + headers: { "Content-Type": "application/json" }, 197 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a", "b"] }), 198 + }); 199 + expect(res.status).toBe(400); 200 + }); 201 + 202 + it("returns 400 when a token value is not a string", async () => { 203 + const res = await app.request("/api/admin/themes", { 204 + method: "POST", 205 + headers: { "Content-Type": "application/json" }, 206 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: { "color-bg": 123 } }), 207 + }); 208 + expect(res.status).toBe(400); 209 + const body = await res.json(); 210 + expect(body.error).toMatch(/tokens/i); 211 + }); 212 + 213 + it("returns 400 when a fontUrl is not HTTPS", async () => { 214 + const res = await app.request("/api/admin/themes", { 215 + method: "POST", 216 + headers: { "Content-Type": "application/json" }, 217 + body: JSON.stringify({ 218 + name: "Test", 219 + colorScheme: "light", 220 + tokens: {}, 221 + fontUrls: ["http://example.com/font.css"], 222 + }), 223 + }); 224 + expect(res.status).toBe(400); 225 + const body = await res.json(); 226 + expect(body.error).toMatch(/https/i); 227 + }); 228 + 229 + it("returns 503 when ForumAgent is not configured", async () => { 230 + ctx.forumAgent = null; 231 + const res = await app.request("/api/admin/themes", { 232 + method: "POST", 233 + headers: { "Content-Type": "application/json" }, 234 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 235 + }); 236 + expect(res.status).toBe(503); 237 + }); 238 + }); 239 + ``` 240 + 241 + **Step 3: Run to verify tests fail** 242 + 243 + ```bash 244 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 245 + ``` 246 + 247 + Expected: tests fail with something like `Cannot find description for POST /api/admin/themes` or 404 responses. 248 + 249 + --- 250 + 251 + ## Task 3: Implement `POST /api/admin/themes` 252 + 253 + **Files:** 254 + - Modify: `apps/appview/src/routes/admin.ts` 255 + 256 + **Step 1: Update imports** 257 + 258 + Add `themes, themePolicies` to the `@atbb/db` import line: 259 + 260 + ```typescript 261 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions, themes, themePolicies } from "@atbb/db"; 262 + ``` 263 + 264 + Add `or` to the `drizzle-orm` import: 265 + 266 + ```typescript 267 + import { eq, and, sql, asc, desc, count, or } from "drizzle-orm"; 268 + ``` 269 + 270 + **Step 2: Add validation helper (inline in the handler)** 271 + 272 + Add the following handler to `admin.ts` before the `return app;` at the bottom. Insert it after the DELETE `/boards/:id` handler and before the GET `/modlog` handler: 273 + 274 + ```typescript 275 + /** 276 + * POST /api/admin/themes 277 + * 278 + * Create a new theme record on Forum DID's PDS. 279 + * Writes space.atbb.forum.theme with a fresh TID rkey. 280 + * The firehose indexer creates the DB row asynchronously. 281 + */ 282 + app.post( 283 + "/themes", 284 + requireAuth(ctx), 285 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 286 + async (c) => { 287 + const { body, error: parseError } = await safeParseJsonBody(c); 288 + if (parseError) return parseError; 289 + 290 + const { name, colorScheme, tokens, cssOverrides, fontUrls } = body; 291 + 292 + if (typeof name !== "string" || name.trim().length === 0) { 293 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 294 + } 295 + if (typeof colorScheme !== "string" || (colorScheme !== "light" && colorScheme !== "dark")) { 296 + return c.json({ error: 'colorScheme is required and must be "light" or "dark"' }, 400); 297 + } 298 + if (tokens === null || tokens === undefined || typeof tokens !== "object" || Array.isArray(tokens)) { 299 + return c.json({ error: "tokens is required and must be a plain object" }, 400); 300 + } 301 + for (const [key, val] of Object.entries(tokens as Record<string, unknown>)) { 302 + if (typeof val !== "string") { 303 + return c.json({ error: `tokens["${key}"] must be a string` }, 400); 304 + } 305 + } 306 + if (cssOverrides !== undefined && typeof cssOverrides !== "string") { 307 + return c.json({ error: "cssOverrides must be a string" }, 400); 308 + } 309 + if (fontUrls !== undefined) { 310 + if (!Array.isArray(fontUrls)) { 311 + return c.json({ error: "fontUrls must be an array of strings" }, 400); 312 + } 313 + for (const url of fontUrls as unknown[]) { 314 + if (typeof url !== "string" || !url.startsWith("https://")) { 315 + return c.json({ error: "fontUrls must contain only HTTPS URLs" }, 400); 316 + } 317 + } 318 + } 319 + 320 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/themes"); 321 + if (agentError) return agentError; 322 + 323 + const rkey = TID.nextStr(); 324 + const now = new Date().toISOString(); 325 + 326 + try { 327 + const result = await agent.com.atproto.repo.putRecord({ 328 + repo: ctx.config.forumDid, 329 + collection: "space.atbb.forum.theme", 330 + rkey, 331 + record: { 332 + $type: "space.atbb.forum.theme", 333 + name: name.trim(), 334 + colorScheme, 335 + tokens, 336 + ...(typeof cssOverrides === "string" && { cssOverrides }), 337 + ...(Array.isArray(fontUrls) && { fontUrls }), 338 + createdAt: now, 339 + }, 340 + }); 341 + 342 + return c.json({ uri: result.data.uri, cid: result.data.cid }, 201); 343 + } catch (error) { 344 + return handleRouteError(c, error, "Failed to create theme", { 345 + operation: "POST /api/admin/themes", 346 + logger: ctx.logger, 347 + }); 348 + } 349 + } 350 + ); 351 + ``` 352 + 353 + **Step 3: Run tests to verify they pass** 354 + 355 + ```bash 356 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 357 + ``` 358 + 359 + Expected: POST /api/admin/themes tests all pass. 360 + 361 + **Step 4: Commit** 362 + 363 + ```bash 364 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 365 + git commit -m "feat(appview): POST /api/admin/themes — create theme on Forum PDS (ATB-57)" 366 + ``` 367 + 368 + --- 369 + 370 + ## Task 4: Write failing tests + implement `PUT /api/admin/themes/:rkey` 371 + 372 + **Files:** 373 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 374 + - Modify: `apps/appview/src/routes/admin.ts` 375 + 376 + **Step 1: Add tests for PUT** 377 + 378 + Add inside `describe.sequential("Admin Routes", ...)` in `admin.test.ts`: 379 + 380 + ```typescript 381 + describe("PUT /api/admin/themes/:rkey", () => { 382 + beforeEach(async () => { 383 + // Seed a theme row for the update tests 384 + await ctx.db.insert(themes).values({ 385 + did: ctx.config.forumDid, 386 + rkey: "3lblputtest1", 387 + cid: "bafyputtest", 388 + name: "Original Name", 389 + colorScheme: "light", 390 + tokens: { "color-bg": "#ffffff" }, 391 + cssOverrides: ".btn { font-weight: 700; }", 392 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"], 393 + createdAt: new Date("2026-01-01T00:00:00Z"), 394 + indexedAt: new Date(), 395 + }); 396 + }); 397 + 398 + it("updates theme and returns 200 with uri and cid", async () => { 399 + const res = await app.request("/api/admin/themes/3lblputtest1", { 400 + method: "PUT", 401 + headers: { "Content-Type": "application/json" }, 402 + body: JSON.stringify({ 403 + name: "Updated Name", 404 + colorScheme: "dark", 405 + tokens: { "color-bg": "#1a1a1a" }, 406 + }), 407 + }); 408 + expect(res.status).toBe(200); 409 + const body = await res.json(); 410 + expect(body.uri).toBeDefined(); 411 + expect(body.cid).toBeDefined(); 412 + }); 413 + 414 + it("preserves existing cssOverrides when not provided in request", async () => { 415 + const res = await app.request("/api/admin/themes/3lblputtest1", { 416 + method: "PUT", 417 + headers: { "Content-Type": "application/json" }, 418 + body: JSON.stringify({ 419 + name: "Updated Name", 420 + colorScheme: "light", 421 + tokens: { "color-bg": "#f0f0f0" }, 422 + // cssOverrides intentionally omitted 423 + }), 424 + }); 425 + expect(res.status).toBe(200); 426 + const call = mockPutRecord.mock.calls[0][0]; 427 + expect(call.record.cssOverrides).toBe(".btn { font-weight: 700; }"); 428 + }); 429 + 430 + it("preserves existing fontUrls when not provided in request", async () => { 431 + const res = await app.request("/api/admin/themes/3lblputtest1", { 432 + method: "PUT", 433 + headers: { "Content-Type": "application/json" }, 434 + body: JSON.stringify({ 435 + name: "Updated Name", 436 + colorScheme: "light", 437 + tokens: {}, 438 + // fontUrls intentionally omitted 439 + }), 440 + }); 441 + expect(res.status).toBe(200); 442 + const call = mockPutRecord.mock.calls[0][0]; 443 + expect(call.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 444 + }); 445 + 446 + it("preserves original createdAt in the PDS record", async () => { 447 + const res = await app.request("/api/admin/themes/3lblputtest1", { 448 + method: "PUT", 449 + headers: { "Content-Type": "application/json" }, 450 + body: JSON.stringify({ name: "Updated", colorScheme: "light", tokens: {} }), 451 + }); 452 + expect(res.status).toBe(200); 453 + const call = mockPutRecord.mock.calls[0][0]; 454 + expect(call.record.createdAt).toBe("2026-01-01T00:00:00.000Z"); 455 + }); 456 + 457 + it("returns 404 for unknown rkey", async () => { 458 + const res = await app.request("/api/admin/themes/nonexistent", { 459 + method: "PUT", 460 + headers: { "Content-Type": "application/json" }, 461 + body: JSON.stringify({ name: "X", colorScheme: "light", tokens: {} }), 462 + }); 463 + expect(res.status).toBe(404); 464 + }); 465 + 466 + it("returns 400 when name is missing", async () => { 467 + const res = await app.request("/api/admin/themes/3lblputtest1", { 468 + method: "PUT", 469 + headers: { "Content-Type": "application/json" }, 470 + body: JSON.stringify({ colorScheme: "light", tokens: {} }), 471 + }); 472 + expect(res.status).toBe(400); 473 + }); 474 + 475 + it("returns 400 when colorScheme is invalid", async () => { 476 + const res = await app.request("/api/admin/themes/3lblputtest1", { 477 + method: "PUT", 478 + headers: { "Content-Type": "application/json" }, 479 + body: JSON.stringify({ name: "Test", colorScheme: "sepia", tokens: {} }), 480 + }); 481 + expect(res.status).toBe(400); 482 + }); 483 + 484 + it("returns 400 when tokens is an array", async () => { 485 + const res = await app.request("/api/admin/themes/3lblputtest1", { 486 + method: "PUT", 487 + headers: { "Content-Type": "application/json" }, 488 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a"] }), 489 + }); 490 + expect(res.status).toBe(400); 491 + }); 492 + }); 493 + ``` 494 + 495 + **Step 2: Run to verify tests fail** 496 + 497 + ```bash 498 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 499 + ``` 500 + 501 + Expected: PUT tests fail with 404 (endpoint doesn't exist yet). 502 + 503 + **Step 3: Implement PUT handler in `admin.ts`** 504 + 505 + Add after the POST `/themes` handler: 506 + 507 + ```typescript 508 + /** 509 + * PUT /api/admin/themes/:rkey 510 + * 511 + * Update an existing theme. Fetches the existing row from DB to 512 + * preserve createdAt and fall back optional fields not in the request. 513 + * The firehose indexer updates the DB row asynchronously. 514 + */ 515 + app.put( 516 + "/themes/:rkey", 517 + requireAuth(ctx), 518 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 519 + async (c) => { 520 + const themeRkey = c.req.param("rkey").trim(); 521 + 522 + const { body, error: parseError } = await safeParseJsonBody(c); 523 + if (parseError) return parseError; 524 + 525 + const { name, colorScheme, tokens, cssOverrides, fontUrls } = body; 526 + 527 + if (typeof name !== "string" || name.trim().length === 0) { 528 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 529 + } 530 + if (typeof colorScheme !== "string" || (colorScheme !== "light" && colorScheme !== "dark")) { 531 + return c.json({ error: 'colorScheme is required and must be "light" or "dark"' }, 400); 532 + } 533 + if (tokens === null || tokens === undefined || typeof tokens !== "object" || Array.isArray(tokens)) { 534 + return c.json({ error: "tokens is required and must be a plain object" }, 400); 535 + } 536 + for (const [key, val] of Object.entries(tokens as Record<string, unknown>)) { 537 + if (typeof val !== "string") { 538 + return c.json({ error: `tokens["${key}"] must be a string` }, 400); 539 + } 540 + } 541 + if (cssOverrides !== undefined && typeof cssOverrides !== "string") { 542 + return c.json({ error: "cssOverrides must be a string" }, 400); 543 + } 544 + if (fontUrls !== undefined) { 545 + if (!Array.isArray(fontUrls)) { 546 + return c.json({ error: "fontUrls must be an array of strings" }, 400); 547 + } 548 + for (const url of fontUrls as unknown[]) { 549 + if (typeof url !== "string" || !url.startsWith("https://")) { 550 + return c.json({ error: "fontUrls must contain only HTTPS URLs" }, 400); 551 + } 552 + } 553 + } 554 + 555 + let theme: typeof themes.$inferSelect; 556 + try { 557 + const [row] = await ctx.db 558 + .select() 559 + .from(themes) 560 + .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, themeRkey))) 561 + .limit(1); 562 + 563 + if (!row) { 564 + return c.json({ error: "Theme not found" }, 404); 565 + } 566 + theme = row; 567 + } catch (error) { 568 + return handleRouteError(c, error, "Failed to look up theme", { 569 + operation: "PUT /api/admin/themes/:rkey", 570 + logger: ctx.logger, 571 + themeRkey, 572 + }); 573 + } 574 + 575 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/themes/:rkey"); 576 + if (agentError) return agentError; 577 + 578 + // putRecord is a full replacement — fall back to existing values for 579 + // optional fields not provided in the request body, to avoid data loss. 580 + const resolvedCssOverrides = typeof cssOverrides === "string" ? cssOverrides : theme.cssOverrides; 581 + const resolvedFontUrls = Array.isArray(fontUrls) ? fontUrls : (theme.fontUrls as string[] | null); 582 + 583 + try { 584 + const result = await agent.com.atproto.repo.putRecord({ 585 + repo: ctx.config.forumDid, 586 + collection: "space.atbb.forum.theme", 587 + rkey: theme.rkey, 588 + record: { 589 + $type: "space.atbb.forum.theme", 590 + name: name.trim(), 591 + colorScheme, 592 + tokens, 593 + ...(resolvedCssOverrides != null && { cssOverrides: resolvedCssOverrides }), 594 + ...(resolvedFontUrls != null && { fontUrls: resolvedFontUrls }), 595 + createdAt: theme.createdAt.toISOString(), 596 + updatedAt: new Date().toISOString(), 597 + }, 598 + }); 599 + 600 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 601 + } catch (error) { 602 + return handleRouteError(c, error, "Failed to update theme", { 603 + operation: "PUT /api/admin/themes/:rkey", 604 + logger: ctx.logger, 605 + themeRkey, 606 + }); 607 + } 608 + } 609 + ); 610 + ``` 611 + 612 + **Step 4: Run tests to verify they pass** 613 + 614 + ```bash 615 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 616 + ``` 617 + 618 + Expected: all PUT tests pass. 619 + 620 + **Step 5: Commit** 621 + 622 + ```bash 623 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 624 + git commit -m "feat(appview): PUT /api/admin/themes/:rkey — update theme on Forum PDS (ATB-57)" 625 + ``` 626 + 627 + --- 628 + 629 + ## Task 5: Write failing tests + implement `DELETE /api/admin/themes/:rkey` 630 + 631 + **Files:** 632 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 633 + - Modify: `apps/appview/src/routes/admin.ts` 634 + 635 + **Step 1: Add tests** 636 + 637 + Import `themePolicies, themePolicyAvailableThemes` at the top of `admin.test.ts`: 638 + 639 + ```typescript 640 + import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions, themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 641 + ``` 642 + 643 + Then add inside `describe.sequential("Admin Routes", ...)`: 644 + 645 + ```typescript 646 + describe("DELETE /api/admin/themes/:rkey", () => { 647 + const themeRkey = "3lbldeltest1"; 648 + const themeUri = `at://${ctx?.config?.forumDid ?? "did:plc:test-forum"}/space.atbb.forum.theme/${themeRkey}`; 649 + 650 + beforeEach(async () => { 651 + await ctx.db.insert(themes).values({ 652 + did: ctx.config.forumDid, 653 + rkey: themeRkey, 654 + cid: "bafydeltest", 655 + name: "Theme To Delete", 656 + colorScheme: "light", 657 + tokens: { "color-bg": "#ffffff" }, 658 + createdAt: new Date(), 659 + indexedAt: new Date(), 660 + }); 661 + }); 662 + 663 + it("deletes theme and returns 200 with success: true", async () => { 664 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 665 + method: "DELETE", 666 + }); 667 + expect(res.status).toBe(200); 668 + const body = await res.json(); 669 + expect(body.success).toBe(true); 670 + expect(mockDeleteRecord).toHaveBeenCalledOnce(); 671 + }); 672 + 673 + it("returns 404 for unknown rkey", async () => { 674 + const res = await app.request("/api/admin/themes/doesnotexist", { 675 + method: "DELETE", 676 + }); 677 + expect(res.status).toBe(404); 678 + }); 679 + 680 + it("returns 409 when theme is the defaultLightTheme in policy", async () => { 681 + const [policy] = await ctx.db.insert(themePolicies).values({ 682 + did: ctx.config.forumDid, 683 + rkey: "self", 684 + cid: "bafypolicydel", 685 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 686 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 687 + allowUserChoice: true, 688 + indexedAt: new Date(), 689 + }).returning(); 690 + 691 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 692 + method: "DELETE", 693 + }); 694 + expect(res.status).toBe(409); 695 + const body = await res.json(); 696 + expect(body.error).toMatch(/default/i); 697 + }); 698 + 699 + it("returns 409 when theme is the defaultDarkTheme in policy", async () => { 700 + await ctx.db.insert(themePolicies).values({ 701 + did: ctx.config.forumDid, 702 + rkey: "self", 703 + cid: "bafypolicydel2", 704 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 705 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 706 + allowUserChoice: true, 707 + indexedAt: new Date(), 708 + }); 709 + 710 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 711 + method: "DELETE", 712 + }); 713 + expect(res.status).toBe(409); 714 + }); 715 + 716 + it("deletes successfully when theme exists in policy availableThemes but not as a default", async () => { 717 + // A theme can be in availableThemes without being a default — this should NOT block deletion 718 + // (the 409 guard only checks defaultLightThemeUri / defaultDarkThemeUri columns) 719 + const [policy] = await ctx.db.insert(themePolicies).values({ 720 + did: ctx.config.forumDid, 721 + rkey: "self", 722 + cid: "bafypolicyavail", 723 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 724 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 725 + allowUserChoice: true, 726 + indexedAt: new Date(), 727 + }).returning(); 728 + await ctx.db.insert(themePolicyAvailableThemes).values({ 729 + policyId: policy.id, 730 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 731 + themeCid: "bafydeltest", 732 + }); 733 + 734 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 735 + method: "DELETE", 736 + }); 737 + expect(res.status).toBe(200); 738 + }); 739 + }); 740 + ``` 741 + 742 + **Step 2: Run to verify tests fail** 743 + 744 + ```bash 745 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 746 + ``` 747 + 748 + Expected: DELETE tests fail. 749 + 750 + **Step 3: Implement DELETE handler in `admin.ts`** 751 + 752 + Add after the PUT `/themes/:rkey` handler: 753 + 754 + ```typescript 755 + /** 756 + * DELETE /api/admin/themes/:rkey 757 + * 758 + * Delete a theme. Pre-flight: refuses with 409 if the theme is set as 759 + * defaultLightTheme or defaultDarkTheme in the theme policy. 760 + * The firehose indexer removes the DB row asynchronously. 761 + */ 762 + app.delete( 763 + "/themes/:rkey", 764 + requireAuth(ctx), 765 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 766 + async (c) => { 767 + const themeRkey = c.req.param("rkey").trim(); 768 + 769 + let theme: typeof themes.$inferSelect; 770 + try { 771 + const [row] = await ctx.db 772 + .select() 773 + .from(themes) 774 + .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, themeRkey))) 775 + .limit(1); 776 + 777 + if (!row) { 778 + return c.json({ error: "Theme not found" }, 404); 779 + } 780 + theme = row; 781 + } catch (error) { 782 + return handleRouteError(c, error, "Failed to look up theme", { 783 + operation: "DELETE /api/admin/themes/:rkey", 784 + logger: ctx.logger, 785 + themeRkey, 786 + }); 787 + } 788 + 789 + // Pre-flight conflict check: refuse if this theme is a policy default 790 + const themeUri = `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`; 791 + try { 792 + const [conflictingPolicy] = await ctx.db 793 + .select({ id: themePolicies.id }) 794 + .from(themePolicies) 795 + .where( 796 + and( 797 + eq(themePolicies.did, ctx.config.forumDid), 798 + or( 799 + eq(themePolicies.defaultLightThemeUri, themeUri), 800 + eq(themePolicies.defaultDarkThemeUri, themeUri) 801 + ) 802 + ) 803 + ) 804 + .limit(1); 805 + 806 + if (conflictingPolicy) { 807 + return c.json( 808 + { error: "Cannot delete a theme that is currently set as a default. Update the theme policy first." }, 809 + 409 810 + ); 811 + } 812 + } catch (error) { 813 + return handleRouteError(c, error, "Failed to check theme policy", { 814 + operation: "DELETE /api/admin/themes/:rkey", 815 + logger: ctx.logger, 816 + themeRkey, 817 + }); 818 + } 819 + 820 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/themes/:rkey"); 821 + if (agentError) return agentError; 822 + 823 + try { 824 + await agent.com.atproto.repo.deleteRecord({ 825 + repo: ctx.config.forumDid, 826 + collection: "space.atbb.forum.theme", 827 + rkey: theme.rkey, 828 + }); 829 + 830 + return c.json({ success: true }); 831 + } catch (error) { 832 + return handleRouteError(c, error, "Failed to delete theme", { 833 + operation: "DELETE /api/admin/themes/:rkey", 834 + logger: ctx.logger, 835 + themeRkey, 836 + }); 837 + } 838 + } 839 + ); 840 + ``` 841 + 842 + **Step 4: Run tests to verify they pass** 843 + 844 + ```bash 845 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 846 + ``` 847 + 848 + Expected: all DELETE tests pass. 849 + 850 + **Step 5: Commit** 851 + 852 + ```bash 853 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 854 + git commit -m "feat(appview): DELETE /api/admin/themes/:rkey — delete theme, 409 if default (ATB-57)" 855 + ``` 856 + 857 + --- 858 + 859 + ## Task 6: Write failing tests + implement `PUT /api/admin/theme-policy` 860 + 861 + **Files:** 862 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 863 + - Modify: `apps/appview/src/routes/admin.ts` 864 + 865 + **Step 1: Add tests** 866 + 867 + Add inside `describe.sequential("Admin Routes", ...)`: 868 + 869 + ```typescript 870 + describe("PUT /api/admin/theme-policy", () => { 871 + const lightUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbllight1`; 872 + const darkUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbldark11`; 873 + 874 + const validBody = { 875 + availableThemes: [ 876 + { uri: lightUri, cid: "bafylight" }, 877 + { uri: darkUri, cid: "bafydark" }, 878 + ], 879 + defaultLightThemeUri: lightUri, 880 + defaultDarkThemeUri: darkUri, 881 + allowUserChoice: true, 882 + }; 883 + 884 + it("creates policy (upsert) and returns 200 with uri and cid", async () => { 885 + const res = await app.request("/api/admin/theme-policy", { 886 + method: "PUT", 887 + headers: { "Content-Type": "application/json" }, 888 + body: JSON.stringify(validBody), 889 + }); 890 + expect(res.status).toBe(200); 891 + const body = await res.json(); 892 + expect(body.uri).toBeDefined(); 893 + expect(body.cid).toBeDefined(); 894 + expect(mockPutRecord).toHaveBeenCalledOnce(); 895 + }); 896 + 897 + it("writes PDS record with themeRef wrapper structure", async () => { 898 + await app.request("/api/admin/theme-policy", { 899 + method: "PUT", 900 + headers: { "Content-Type": "application/json" }, 901 + body: JSON.stringify(validBody), 902 + }); 903 + const call = mockPutRecord.mock.calls[0][0]; 904 + expect(call.record.$type).toBe("space.atbb.forum.themePolicy"); 905 + expect(call.rkey).toBe("self"); 906 + // availableThemes wrapped in { theme: { uri, cid } } 907 + expect(call.record.availableThemes[0]).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 908 + expect(call.record.defaultLightTheme).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 909 + expect(call.record.defaultDarkTheme).toEqual({ theme: { uri: darkUri, cid: "bafydark" } }); 910 + expect(call.record.allowUserChoice).toBe(true); 911 + }); 912 + 913 + it("defaults allowUserChoice to true when not provided", async () => { 914 + const { allowUserChoice: _, ...bodyWithout } = validBody; 915 + await app.request("/api/admin/theme-policy", { 916 + method: "PUT", 917 + headers: { "Content-Type": "application/json" }, 918 + body: JSON.stringify(bodyWithout), 919 + }); 920 + const call = mockPutRecord.mock.calls[0][0]; 921 + expect(call.record.allowUserChoice).toBe(true); 922 + }); 923 + 924 + it("returns 400 when availableThemes is missing", async () => { 925 + const { availableThemes: _, ...bodyWithout } = validBody; 926 + const res = await app.request("/api/admin/theme-policy", { 927 + method: "PUT", 928 + headers: { "Content-Type": "application/json" }, 929 + body: JSON.stringify(bodyWithout), 930 + }); 931 + expect(res.status).toBe(400); 932 + const body = await res.json(); 933 + expect(body.error).toMatch(/availableThemes/i); 934 + }); 935 + 936 + it("returns 400 when availableThemes is empty array", async () => { 937 + const res = await app.request("/api/admin/theme-policy", { 938 + method: "PUT", 939 + headers: { "Content-Type": "application/json" }, 940 + body: JSON.stringify({ ...validBody, availableThemes: [] }), 941 + }); 942 + expect(res.status).toBe(400); 943 + }); 944 + 945 + it("returns 400 when availableThemes item is missing cid", async () => { 946 + const res = await app.request("/api/admin/theme-policy", { 947 + method: "PUT", 948 + headers: { "Content-Type": "application/json" }, 949 + body: JSON.stringify({ 950 + ...validBody, 951 + availableThemes: [{ uri: lightUri }], // missing cid 952 + defaultLightThemeUri: lightUri, 953 + defaultDarkThemeUri: lightUri, 954 + }), 955 + }); 956 + expect(res.status).toBe(400); 957 + }); 958 + 959 + it("returns 400 when defaultLightThemeUri is not in availableThemes", async () => { 960 + const res = await app.request("/api/admin/theme-policy", { 961 + method: "PUT", 962 + headers: { "Content-Type": "application/json" }, 963 + body: JSON.stringify({ 964 + ...validBody, 965 + defaultLightThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist", 966 + }), 967 + }); 968 + expect(res.status).toBe(400); 969 + const body = await res.json(); 970 + expect(body.error).toMatch(/defaultLightThemeUri/i); 971 + }); 972 + 973 + it("returns 400 when defaultDarkThemeUri is not in availableThemes", async () => { 974 + const res = await app.request("/api/admin/theme-policy", { 975 + method: "PUT", 976 + headers: { "Content-Type": "application/json" }, 977 + body: JSON.stringify({ 978 + ...validBody, 979 + defaultDarkThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist", 980 + }), 981 + }); 982 + expect(res.status).toBe(400); 983 + const body = await res.json(); 984 + expect(body.error).toMatch(/defaultDarkThemeUri/i); 985 + }); 986 + 987 + it("returns 400 when defaultLightThemeUri is missing", async () => { 988 + const { defaultLightThemeUri: _, ...bodyWithout } = validBody; 989 + const res = await app.request("/api/admin/theme-policy", { 990 + method: "PUT", 991 + headers: { "Content-Type": "application/json" }, 992 + body: JSON.stringify(bodyWithout), 993 + }); 994 + expect(res.status).toBe(400); 995 + }); 996 + 997 + it("returns 400 when defaultDarkThemeUri is missing", async () => { 998 + const { defaultDarkThemeUri: _, ...bodyWithout } = validBody; 999 + const res = await app.request("/api/admin/theme-policy", { 1000 + method: "PUT", 1001 + headers: { "Content-Type": "application/json" }, 1002 + body: JSON.stringify(bodyWithout), 1003 + }); 1004 + expect(res.status).toBe(400); 1005 + }); 1006 + 1007 + it("returns 503 when ForumAgent is not configured", async () => { 1008 + ctx.forumAgent = null; 1009 + const res = await app.request("/api/admin/theme-policy", { 1010 + method: "PUT", 1011 + headers: { "Content-Type": "application/json" }, 1012 + body: JSON.stringify(validBody), 1013 + }); 1014 + expect(res.status).toBe(503); 1015 + }); 1016 + }); 1017 + ``` 1018 + 1019 + **Step 2: Run to verify tests fail** 1020 + 1021 + ```bash 1022 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 1023 + ``` 1024 + 1025 + Expected: PUT /admin/theme-policy tests fail with 404. 1026 + 1027 + **Step 3: Implement PUT /theme-policy handler in `admin.ts`** 1028 + 1029 + Add after the DELETE `/themes/:rkey` handler (and before GET `/modlog`): 1030 + 1031 + ```typescript 1032 + /** 1033 + * PUT /api/admin/theme-policy 1034 + * 1035 + * Create or update the themePolicy singleton (rkey: "self") on Forum DID's PDS. 1036 + * Upsert semantics: works whether or not a policy record exists yet. 1037 + * The firehose indexer creates/updates the DB row asynchronously. 1038 + */ 1039 + app.put( 1040 + "/theme-policy", 1041 + requireAuth(ctx), 1042 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 1043 + async (c) => { 1044 + const { body, error: parseError } = await safeParseJsonBody(c); 1045 + if (parseError) return parseError; 1046 + 1047 + const { availableThemes, defaultLightThemeUri, defaultDarkThemeUri, allowUserChoice } = body; 1048 + 1049 + if (!Array.isArray(availableThemes) || availableThemes.length === 0) { 1050 + return c.json({ error: "availableThemes is required and must be a non-empty array" }, 400); 1051 + } 1052 + for (const t of availableThemes as unknown[]) { 1053 + if ( 1054 + typeof (t as any)?.uri !== "string" || 1055 + typeof (t as any)?.cid !== "string" 1056 + ) { 1057 + return c.json({ error: "Each availableThemes entry must have uri and cid string fields" }, 400); 1058 + } 1059 + } 1060 + 1061 + if (typeof defaultLightThemeUri !== "string" || !defaultLightThemeUri.startsWith("at://")) { 1062 + return c.json({ error: "defaultLightThemeUri is required and must be an AT-URI" }, 400); 1063 + } 1064 + if (typeof defaultDarkThemeUri !== "string" || !defaultDarkThemeUri.startsWith("at://")) { 1065 + return c.json({ error: "defaultDarkThemeUri is required and must be an AT-URI" }, 400); 1066 + } 1067 + 1068 + const availableUris = (availableThemes as Array<{ uri: string; cid: string }>).map((t) => t.uri); 1069 + if (!availableUris.includes(defaultLightThemeUri)) { 1070 + return c.json({ error: "defaultLightThemeUri must be present in availableThemes" }, 400); 1071 + } 1072 + if (!availableUris.includes(defaultDarkThemeUri)) { 1073 + return c.json({ error: "defaultDarkThemeUri must be present in availableThemes" }, 400); 1074 + } 1075 + 1076 + const resolvedAllowUserChoice = typeof allowUserChoice === "boolean" ? allowUserChoice : true; 1077 + 1078 + const typedAvailableThemes = availableThemes as Array<{ uri: string; cid: string }>; 1079 + const lightTheme = typedAvailableThemes.find((t) => t.uri === defaultLightThemeUri)!; 1080 + const darkTheme = typedAvailableThemes.find((t) => t.uri === defaultDarkThemeUri)!; 1081 + 1082 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/theme-policy"); 1083 + if (agentError) return agentError; 1084 + 1085 + try { 1086 + const result = await agent.com.atproto.repo.putRecord({ 1087 + repo: ctx.config.forumDid, 1088 + collection: "space.atbb.forum.themePolicy", 1089 + rkey: "self", 1090 + record: { 1091 + $type: "space.atbb.forum.themePolicy", 1092 + availableThemes: typedAvailableThemes.map((t) => ({ 1093 + theme: { uri: t.uri, cid: t.cid }, 1094 + })), 1095 + defaultLightTheme: { theme: { uri: lightTheme.uri, cid: lightTheme.cid } }, 1096 + defaultDarkTheme: { theme: { uri: darkTheme.uri, cid: darkTheme.cid } }, 1097 + allowUserChoice: resolvedAllowUserChoice, 1098 + updatedAt: new Date().toISOString(), 1099 + }, 1100 + }); 1101 + 1102 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 1103 + } catch (error) { 1104 + return handleRouteError(c, error, "Failed to update theme policy", { 1105 + operation: "PUT /api/admin/theme-policy", 1106 + logger: ctx.logger, 1107 + }); 1108 + } 1109 + } 1110 + ); 1111 + ``` 1112 + 1113 + **Step 4: Run tests to verify they pass** 1114 + 1115 + ```bash 1116 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 1117 + ``` 1118 + 1119 + Expected: all theme-policy tests pass. 1120 + 1121 + **Step 5: Run full test suite** 1122 + 1123 + ```bash 1124 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run 1125 + ``` 1126 + 1127 + Expected: all tests pass. 1128 + 1129 + **Step 6: Commit** 1130 + 1131 + ```bash 1132 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 1133 + git commit -m "feat(appview): PUT /api/admin/theme-policy — upsert policy singleton on Forum PDS (ATB-57)" 1134 + ``` 1135 + 1136 + --- 1137 + 1138 + ## Task 7: Add Bruno collection files 1139 + 1140 + **Files:** 1141 + - Create: `bruno/AppView API/Admin Themes/Create Theme.bru` 1142 + - Create: `bruno/AppView API/Admin Themes/Update Theme.bru` 1143 + - Create: `bruno/AppView API/Admin Themes/Delete Theme.bru` 1144 + - Create: `bruno/AppView API/Admin Themes/Update Theme Policy.bru` 1145 + 1146 + **Step 1: Create directory and files** 1147 + 1148 + Create `bruno/AppView API/Admin Themes/Create Theme.bru`: 1149 + 1150 + ```bru 1151 + meta { 1152 + name: Create Theme 1153 + type: http 1154 + seq: 1 1155 + } 1156 + 1157 + post { 1158 + url: {{appview_url}}/api/admin/themes 1159 + } 1160 + 1161 + body:json { 1162 + { 1163 + "name": "Neobrutal Light", 1164 + "colorScheme": "light", 1165 + "tokens": { 1166 + "color-bg": "#f5f0e8", 1167 + "color-text": "#1a1a1a", 1168 + "color-primary": "#ff5c00" 1169 + }, 1170 + "fontUrls": ["https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700"] 1171 + } 1172 + } 1173 + 1174 + assert { 1175 + res.status: eq 201 1176 + res.body.uri: isDefined 1177 + res.body.cid: isDefined 1178 + } 1179 + 1180 + docs { 1181 + Create a new theme record on the Forum DID's PDS. 1182 + The firehose indexer creates the DB row asynchronously. 1183 + 1184 + **Requires:** space.atbb.permission.manageThemes 1185 + 1186 + Body: 1187 + - name (required): Theme display name, non-empty 1188 + - colorScheme (required): "light" or "dark" 1189 + - tokens (required): Plain object of CSS design token key-value pairs. Values must be strings. 1190 + - cssOverrides (optional): Raw CSS string for structural overrides (not rendered until ATB-62 sanitization ships) 1191 + - fontUrls (optional): Array of HTTPS URLs for font stylesheets 1192 + 1193 + Returns (201): 1194 + { 1195 + "uri": "at://did:plc:.../space.atbb.forum.theme/abc123", 1196 + "cid": "bafyrei..." 1197 + } 1198 + 1199 + Error codes: 1200 + - 400: Missing name/colorScheme/tokens, invalid colorScheme, non-HTTPS fontUrl, token value not a string, malformed JSON 1201 + - 401: Not authenticated 1202 + - 403: Missing manageThemes permission 1203 + - 503: ForumAgent not configured or PDS network error 1204 + } 1205 + ``` 1206 + 1207 + Create `bruno/AppView API/Admin Themes/Update Theme.bru`: 1208 + 1209 + ```bru 1210 + meta { 1211 + name: Update Theme 1212 + type: http 1213 + seq: 2 1214 + } 1215 + 1216 + put { 1217 + url: {{appview_url}}/api/admin/themes/{{theme_rkey}} 1218 + } 1219 + 1220 + body:json { 1221 + { 1222 + "name": "Neobrutal Light (Updated)", 1223 + "colorScheme": "light", 1224 + "tokens": { 1225 + "color-bg": "#f5f0e8", 1226 + "color-text": "#1a1a1a", 1227 + "color-primary": "#ff5c00" 1228 + } 1229 + } 1230 + } 1231 + 1232 + assert { 1233 + res.status: eq 200 1234 + res.body.uri: isDefined 1235 + res.body.cid: isDefined 1236 + } 1237 + 1238 + docs { 1239 + Update an existing theme record. Full replacement of the PDS record. 1240 + Optional fields (cssOverrides, fontUrls) fall back to their existing values 1241 + when omitted from the request body. 1242 + 1243 + **Requires:** space.atbb.permission.manageThemes 1244 + 1245 + Path params: 1246 + - rkey: Theme record key (TID) 1247 + 1248 + Body: same as Create Theme (all fields). 1249 + 1250 + Returns (200): 1251 + { 1252 + "uri": "at://did:plc:.../space.atbb.forum.theme/abc123", 1253 + "cid": "bafyrei..." 1254 + } 1255 + 1256 + Error codes: 1257 + - 400: Invalid input (same as Create Theme) 1258 + - 401: Not authenticated 1259 + - 403: Missing manageThemes permission 1260 + - 404: Theme not found 1261 + - 503: ForumAgent not configured or PDS network error 1262 + } 1263 + ``` 1264 + 1265 + Create `bruno/AppView API/Admin Themes/Delete Theme.bru`: 1266 + 1267 + ```bru 1268 + meta { 1269 + name: Delete Theme 1270 + type: http 1271 + seq: 3 1272 + } 1273 + 1274 + delete { 1275 + url: {{appview_url}}/api/admin/themes/{{theme_rkey}} 1276 + } 1277 + 1278 + assert { 1279 + res.status: eq 200 1280 + res.body.success: eq true 1281 + } 1282 + 1283 + docs { 1284 + Delete a theme record. Fails with 409 if the theme is currently set as 1285 + the defaultLightTheme or defaultDarkTheme in the theme policy. 1286 + 1287 + **Requires:** space.atbb.permission.manageThemes 1288 + 1289 + Path params: 1290 + - rkey: Theme record key (TID) 1291 + 1292 + Returns (200): 1293 + { 1294 + "success": true 1295 + } 1296 + 1297 + Error codes: 1298 + - 401: Not authenticated 1299 + - 403: Missing manageThemes permission 1300 + - 404: Theme not found 1301 + - 409: Theme is the current defaultLightTheme or defaultDarkTheme — update theme policy first 1302 + - 503: ForumAgent not configured or PDS network error 1303 + } 1304 + ``` 1305 + 1306 + Create `bruno/AppView API/Admin Themes/Update Theme Policy.bru`: 1307 + 1308 + ```bru 1309 + meta { 1310 + name: Update Theme Policy 1311 + type: http 1312 + seq: 4 1313 + } 1314 + 1315 + put { 1316 + url: {{appview_url}}/api/admin/theme-policy 1317 + } 1318 + 1319 + body:json { 1320 + { 1321 + "availableThemes": [ 1322 + { "uri": "at://did:plc:example/space.atbb.forum.theme/3lbllight1", "cid": "bafylight" }, 1323 + { "uri": "at://did:plc:example/space.atbb.forum.theme/3lbldark11", "cid": "bafydark" } 1324 + ], 1325 + "defaultLightThemeUri": "at://did:plc:example/space.atbb.forum.theme/3lbllight1", 1326 + "defaultDarkThemeUri": "at://did:plc:example/space.atbb.forum.theme/3lbldark11", 1327 + "allowUserChoice": true 1328 + } 1329 + } 1330 + 1331 + assert { 1332 + res.status: eq 200 1333 + res.body.uri: isDefined 1334 + res.body.cid: isDefined 1335 + } 1336 + 1337 + docs { 1338 + Create or update the themePolicy singleton on the Forum DID's PDS. 1339 + Uses upsert semantics: works whether or not a policy record exists yet. 1340 + 1341 + **Requires:** space.atbb.permission.manageThemes 1342 + 1343 + Body: 1344 + - availableThemes (required): Non-empty array of { uri, cid } theme references. 1345 + Both defaultLightThemeUri and defaultDarkThemeUri must be present in this list. 1346 + - defaultLightThemeUri (required): AT-URI of the default light-mode theme. 1347 + Must be in availableThemes. 1348 + - defaultDarkThemeUri (required): AT-URI of the default dark-mode theme. 1349 + Must be in availableThemes. 1350 + - allowUserChoice (optional, default true): Whether users can pick their own theme. 1351 + 1352 + Returns (200): 1353 + { 1354 + "uri": "at://did:plc:.../space.atbb.forum.themePolicy/self", 1355 + "cid": "bafyrei..." 1356 + } 1357 + 1358 + Error codes: 1359 + - 400: Missing/empty availableThemes, missing defaultLightThemeUri/defaultDarkThemeUri, 1360 + default URI not in availableThemes list, malformed JSON 1361 + - 401: Not authenticated 1362 + - 403: Missing manageThemes permission 1363 + - 503: ForumAgent not configured or PDS network error 1364 + } 1365 + ``` 1366 + 1367 + **Step 2: Verify the collection directory exists and files are created** 1368 + 1369 + ```bash 1370 + ls "bruno/AppView API/Admin Themes/" 1371 + ``` 1372 + 1373 + Expected: 4 `.bru` files listed. 1374 + 1375 + **Step 3: Run full test suite one final time** 1376 + 1377 + ```bash 1378 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run 1379 + ``` 1380 + 1381 + Expected: all tests pass. 1382 + 1383 + **Step 4: Commit** 1384 + 1385 + ```bash 1386 + git add "bruno/AppView API/Admin Themes/" 1387 + git commit -m "docs(bruno): add Admin Themes collection for ATB-57 write endpoints" 1388 + ``` 1389 + 1390 + --- 1391 + 1392 + ## Task 8: Linear + plan doc update 1393 + 1394 + **Step 1: Mark plan doc complete** 1395 + 1396 + In `docs/plans/2026-03-02-atb-57-theme-write-api.md`, update the status line to: 1397 + ``` 1398 + **Status:** Complete (ATB-57) 1399 + ``` 1400 + 1401 + Rename/move the plan doc to `docs/plans/complete/`: 1402 + ```bash 1403 + mv docs/plans/2026-03-02-atb-57-theme-write-api.md docs/plans/complete/2026-03-02-atb-57-theme-write-api.md 1404 + mv docs/plans/2026-03-02-theme-write-api-design.md docs/plans/complete/2026-03-02-theme-write-api-design.md 1405 + ``` 1406 + 1407 + **Step 2: Commit plan doc move** 1408 + 1409 + ```bash 1410 + git add docs/plans/ 1411 + git commit -m "docs: mark ATB-57 plan docs complete, move to docs/plans/complete/" 1412 + ``` 1413 + 1414 + **Step 3: Update Linear** 1415 + 1416 + - Set ATB-57 status to **Done** 1417 + - Add a comment: "Implemented POST/PUT/DELETE /api/admin/themes and PUT /api/admin/theme-policy in admin.ts. Added manageThemes permission to Admin role in seed-roles.ts. Bruno collection added under Admin Themes/. All tests pass."
+178
docs/plans/complete/2026-03-02-theme-write-api-design.md
··· 1 + # Theme Write API Endpoints — Design 2 + 3 + **Linear:** ATB-57 4 + **Date:** 2026-03-02 5 + **Status:** Approved, ready for implementation 6 + 7 + --- 8 + 9 + ## Context 10 + 11 + The AppView needs write endpoints so admins can create, update, and delete themes, and manage the theme policy. These follow the PDS-first pattern established by category and board management. 12 + 13 + **Depends on:** ATB-51 (theme lexicons), ATB-55 (theme read endpoints + DB tables) 14 + 15 + --- 16 + 17 + ## Route Placement 18 + 19 + All four endpoints are added to `apps/appview/src/routes/admin.ts`, alongside existing category/board write endpoints. The admin router is already mounted at `/admin` in `index.ts` — no routing changes needed. 20 + 21 + --- 22 + 23 + ## Endpoints 24 + 25 + | Method | Path | Permission | 26 + |--------|------|-----------| 27 + | `POST` | `/api/admin/themes` | `space.atbb.permission.manageThemes` | 28 + | `PUT` | `/api/admin/themes/:rkey` | `space.atbb.permission.manageThemes` | 29 + | `DELETE` | `/api/admin/themes/:rkey` | `space.atbb.permission.manageThemes` | 30 + | `PUT` | `/api/admin/theme-policy` | `space.atbb.permission.manageThemes` | 31 + 32 + --- 33 + 34 + ## Permission Changes 35 + 36 + Add `space.atbb.permission.manageThemes` to `apps/appview/src/lib/seed-roles.ts`: 37 + 38 + - **Owner**: already has `"*"` wildcard — no change 39 + - **Admin**: add `manageThemes` to the permissions array 40 + - **Moderator / Member**: no change 41 + 42 + --- 43 + 44 + ## Input Validation 45 + 46 + ### Theme (POST and PUT) 47 + 48 + | Field | Rule | 49 + |-------|------| 50 + | `name` | Required string, non-empty, ≤ 100 graphemes | 51 + | `colorScheme` | Required, must be `"light"` or `"dark"` | 52 + | `tokens` | Required, must be a non-null object; values must be strings | 53 + | `cssOverrides` | Optional string (do NOT render until ATB-62 CSS sanitization ships) | 54 + | `fontUrls` | Optional array of strings; each must start with `"https://"` | 55 + 56 + Token keys are **not** validated against a known list (lenient mode — allows custom/future tokens). 57 + 58 + ### Theme Policy (PUT) 59 + 60 + | Field | Rule | 61 + |-------|------| 62 + | `availableThemes` | Required non-empty array of `{ uri: string, cid: string }` | 63 + | `defaultLightThemeUri` | Required string; must be an AT-URI present in `availableThemes` | 64 + | `defaultDarkThemeUri` | Required string; must be an AT-URI present in `availableThemes` | 65 + | `allowUserChoice` | Optional boolean, defaults `true` | 66 + 67 + --- 68 + 69 + ## Endpoint Details 70 + 71 + ### `POST /api/admin/themes` 72 + 73 + 1. Parse and validate request body 74 + 2. Get ForumAgent (return 503 if unavailable) 75 + 3. Generate `rkey = TID.nextStr()` 76 + 4. `putRecord` on Forum DID's PDS with `collection: "space.atbb.forum.theme"` 77 + 5. Return `{ uri, cid }` with `201` 78 + 79 + Does not wait for firehose indexing — the PDS write is the authoritative action. 80 + 81 + ### `PUT /api/admin/themes/:rkey` 82 + 83 + 1. Parse and validate request body 84 + 2. Look up existing theme by `rkey` + `forumDid` in DB (404 if missing) 85 + 3. Get ForumAgent 86 + 4. `putRecord` with same rkey, preserving `createdAt` from DB row 87 + 5. Optional fields (`cssOverrides`, `fontUrls`, `description`) fall back to existing DB values if not provided in request 88 + 6. Return `{ uri, cid }` with `200` 89 + 90 + ### `DELETE /api/admin/themes/:rkey` 91 + 92 + 1. Look up theme in DB (404 if missing) 93 + 2. Pre-flight conflict check: query `theme_policies` for rows where `default_light_theme_uri` OR `default_dark_theme_uri` = this theme's AT-URI 94 + 3. Return `409` if any match 95 + 4. Get ForumAgent 96 + 5. `deleteRecord` on Forum DID's PDS 97 + 6. Return `{ success: true }` with `200` 98 + 99 + ### `PUT /api/admin/theme-policy` 100 + 101 + Upsert semantics (creates if no policy row exists yet, updates if one does). 102 + 103 + 1. Parse and validate request body 104 + 2. Validate `defaultLightThemeUri` is present in `availableThemes` (400 if not) 105 + 3. Validate `defaultDarkThemeUri` is present in `availableThemes` (400 if not) 106 + 4. Get ForumAgent 107 + 5. `putRecord` with `rkey: "self"`, `collection: "space.atbb.forum.themePolicy"` 108 + 6. PDS record structure follows the `themeRef` wrapper pattern from the lexicon: `{ theme: { uri, cid } }` 109 + 7. Return `{ uri, cid }` with `200` 110 + 111 + --- 112 + 113 + ## Error Codes 114 + 115 + | Status | Condition | 116 + |--------|-----------| 117 + | 400 | Invalid/missing input field, invalid colorScheme, non-HTTPS fontUrl, default theme not in availableThemes | 118 + | 401 | Not authenticated | 119 + | 403 | Caller lacks `manageThemes` permission | 120 + | 404 | Theme rkey not found (PUT/DELETE) | 121 + | 409 | DELETE attempted on a theme that is the current policy default | 122 + | 503 | DB or PDS connectivity error | 123 + 124 + --- 125 + 126 + ## Tests 127 + 128 + ### `POST /api/admin/themes` 129 + - Happy path: returns 201 with uri and cid 130 + - Missing `name` → 400 131 + - Empty `name` → 400 132 + - `name` too long (> 100 graphemes) → 400 133 + - Invalid `colorScheme` (not light/dark) → 400 134 + - Missing `colorScheme` → 400 135 + - `tokens` not an object → 400 136 + - Missing `tokens` → 400 137 + - Non-HTTPS fontUrl → 400 138 + - Permission denied (no manageThemes) → 403 139 + - Unauthenticated → 401 140 + - PDS/DB error → 503 141 + 142 + ### `PUT /api/admin/themes/:rkey` 143 + - Happy path: updates theme, returns 200 144 + - Partial update (no cssOverrides in body) preserves existing cssOverrides 145 + - Unknown rkey → 404 146 + - Same input validation failures as POST → 400 147 + - Permission denied → 403 148 + 149 + ### `DELETE /api/admin/themes/:rkey` 150 + - Happy path: deletes theme, returns 200 151 + - Unknown rkey → 404 152 + - Theme is defaultLightTheme in policy → 409 153 + - Theme is defaultDarkTheme in policy → 409 154 + - Permission denied → 403 155 + 156 + ### `PUT /api/admin/theme-policy` 157 + - Happy path create (no existing policy): returns 200 158 + - Happy path update (policy already exists): returns 200 159 + - `defaultLightThemeUri` not in `availableThemes` → 400 160 + - `defaultDarkThemeUri` not in `availableThemes` → 400 161 + - Missing `availableThemes` → 400 162 + - Empty `availableThemes` array → 400 163 + - Missing `defaultLightThemeUri` → 400 164 + - Missing `defaultDarkThemeUri` → 400 165 + - Permission denied → 403 166 + 167 + --- 168 + 169 + ## Bruno Collection 170 + 171 + New files in `bruno/AppView API/Admin Themes/`: 172 + 173 + - `Create Theme.bru` 174 + - `Update Theme.bru` 175 + - `Delete Theme.bru` 176 + - `Update Theme Policy.bru` 177 + 178 + All use `{{appview_url}}` for the base URL and include error code documentation.
+50
packages/db/src/schema.sqlite.ts
··· 249 249 }, 250 250 (table) => [index("backfill_errors_backfill_id_idx").on(table.backfillId)] 251 251 ); 252 + 253 + // ── themes ────────────────────────────────────────────── 254 + export const themes = sqliteTable( 255 + "themes", 256 + { 257 + id: integer("id").primaryKey({ autoIncrement: true }), 258 + did: text("did").notNull(), 259 + rkey: text("rkey").notNull(), 260 + cid: text("cid").notNull(), 261 + name: text("name").notNull(), 262 + colorScheme: text("color_scheme").notNull(), 263 + tokens: text("tokens", { mode: "json" }).notNull(), // auto JSON parse/stringify 264 + cssOverrides: text("css_overrides"), 265 + fontUrls: text("font_urls", { mode: "json" }), // auto JSON parse/stringify 266 + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 267 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 268 + }, 269 + (table) => [uniqueIndex("themes_did_rkey_idx").on(table.did, table.rkey)] 270 + ); 271 + 272 + // ── theme_policies ─────────────────────────────────────── 273 + export const themePolicies = sqliteTable( 274 + "theme_policies", 275 + { 276 + id: integer("id").primaryKey({ autoIncrement: true }), 277 + did: text("did").notNull(), 278 + rkey: text("rkey").notNull(), 279 + cid: text("cid").notNull(), 280 + defaultLightThemeUri: text("default_light_theme_uri").notNull(), 281 + defaultDarkThemeUri: text("default_dark_theme_uri").notNull(), 282 + allowUserChoice: integer("allow_user_choice", { mode: "boolean" }).notNull(), 283 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 284 + }, 285 + (table) => [ 286 + uniqueIndex("theme_policies_did_rkey_idx").on(table.did, table.rkey), 287 + ] 288 + ); 289 + 290 + // ── theme_policy_available_themes ──────────────────────── 291 + export const themePolicyAvailableThemes = sqliteTable( 292 + "theme_policy_available_themes", 293 + { 294 + policyId: integer("policy_id") 295 + .notNull() 296 + .references(() => themePolicies.id, { onDelete: "cascade" }), 297 + themeUri: text("theme_uri").notNull(), 298 + themeCid: text("theme_cid").notNull(), 299 + }, 300 + (t) => [primaryKey({ columns: [t.policyId, t.themeUri] })] 301 + );
+57
packages/db/src/schema.ts
··· 6 6 integer, 7 7 boolean, 8 8 bigint, 9 + jsonb, 9 10 uniqueIndex, 10 11 index, 11 12 primaryKey, ··· 258 259 }, 259 260 (table) => [index("backfill_errors_backfill_id_idx").on(table.backfillId)] 260 261 ); 262 + 263 + // ── themes ────────────────────────────────────────────── 264 + // Theme definitions, owned by Forum DID. Multiple themes per forum. 265 + // Key: tid (multiple records per repo). 266 + export const themes = pgTable( 267 + "themes", 268 + { 269 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 270 + did: text("did").notNull(), 271 + rkey: text("rkey").notNull(), 272 + cid: text("cid").notNull(), 273 + name: text("name").notNull(), 274 + colorScheme: text("color_scheme").notNull(), // "light" | "dark" 275 + tokens: jsonb("tokens").notNull(), // design token key-value map 276 + cssOverrides: text("css_overrides"), 277 + fontUrls: text("font_urls").array(), 278 + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 279 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 280 + }, 281 + (table) => [uniqueIndex("themes_did_rkey_idx").on(table.did, table.rkey)] 282 + ); 283 + 284 + // ── theme_policies ─────────────────────────────────────── 285 + // Singleton theme policy, owned by Forum DID. 286 + // Key: literal:self (rkey is always "self"). 287 + export const themePolicies = pgTable( 288 + "theme_policies", 289 + { 290 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 291 + did: text("did").notNull(), 292 + rkey: text("rkey").notNull(), 293 + cid: text("cid").notNull(), 294 + defaultLightThemeUri: text("default_light_theme_uri").notNull(), 295 + defaultDarkThemeUri: text("default_dark_theme_uri").notNull(), 296 + allowUserChoice: boolean("allow_user_choice").notNull(), 297 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 298 + }, 299 + (table) => [ 300 + uniqueIndex("theme_policies_did_rkey_idx").on(table.did, table.rkey), 301 + ] 302 + ); 303 + 304 + // ── theme_policy_available_themes ──────────────────────── 305 + // Normalized join table: which themes does the policy expose to users? 306 + // ON DELETE CASCADE: deleting a policy row removes all its available-theme rows. 307 + export const themePolicyAvailableThemes = pgTable( 308 + "theme_policy_available_themes", 309 + { 310 + policyId: bigint("policy_id", { mode: "bigint" }) 311 + .notNull() 312 + .references(() => themePolicies.id, { onDelete: "cascade" }), 313 + themeUri: text("theme_uri").notNull(), 314 + themeCid: text("theme_cid").notNull(), 315 + }, 316 + (t) => [primaryKey({ columns: [t.policyId, t.themeUri] })] 317 + );
+64
packages/lexicon/lexicons/space/atbb/forum/theme.yaml
··· 1 + # yaml-language-server: $schema=https://boat.kelinci.net/lexicon-document.json 2 + --- 3 + lexicon: 1 4 + id: space.atbb.forum.theme 5 + defs: 6 + main: 7 + type: record 8 + description: >- 9 + A theme definition for an atBB forum. 10 + Themes store design tokens as key-value pairs that map to CSS custom 11 + properties, plus optional CSS overrides and font URLs. 12 + Owned by the Forum DID. 13 + key: tid 14 + record: 15 + type: object 16 + required: 17 + - name 18 + - colorScheme 19 + - tokens 20 + - createdAt 21 + properties: 22 + name: 23 + type: string 24 + maxLength: 300 25 + maxGraphemes: 100 26 + description: >- 27 + Human-readable theme name (e.g., "Neobrutal Default", "Dark Mode"). 28 + colorScheme: 29 + type: string 30 + knownValues: 31 + - light 32 + - dark 33 + description: >- 34 + Which color scheme mode this theme targets. 35 + tokens: 36 + type: unknown 37 + description: >- 38 + Design token key-value pairs mapping to CSS custom properties. 39 + Keys are token names (e.g., "color-bg"), values are CSS values 40 + (e.g., "#f5f0e8"). Stored as a JSON object. 41 + cssOverrides: 42 + type: string 43 + maxLength: 100000 44 + maxGraphemes: 50000 45 + description: >- 46 + Raw CSS for structural overrides beyond token values. 47 + fontUrls: 48 + type: array 49 + description: >- 50 + HTTPS URLs for external font stylesheets 51 + (e.g., Google Fonts, self-hosted). 52 + items: 53 + type: string 54 + format: uri 55 + createdAt: 56 + type: string 57 + format: datetime 58 + description: >- 59 + Timestamp when this theme was created. 60 + updatedAt: 61 + type: string 62 + format: datetime 63 + description: >- 64 + Timestamp when this theme was last modified.
+65
packages/lexicon/lexicons/space/atbb/forum/themePolicy.yaml
··· 1 + # yaml-language-server: $schema=https://boat.kelinci.net/lexicon-document.json 2 + --- 3 + lexicon: 1 4 + id: space.atbb.forum.themePolicy 5 + defs: 6 + main: 7 + type: record 8 + description: >- 9 + Theme configuration policy for an atBB forum. 10 + Controls which themes are available to users, the defaults for 11 + light and dark mode, and whether users can choose their own theme. 12 + Separated from the main forum record to allow independent updates 13 + without invalidating strongRefs to the forum record. 14 + Owned by the Forum DID. 15 + key: literal:self 16 + record: 17 + type: object 18 + required: 19 + - availableThemes 20 + - defaultLightTheme 21 + - defaultDarkTheme 22 + - allowUserChoice 23 + properties: 24 + availableThemes: 25 + type: array 26 + description: >- 27 + Themes the admin has enabled for users. Both defaultLightTheme 28 + and defaultDarkTheme must be members of this list. 29 + items: 30 + type: ref 31 + ref: "#themeRef" 32 + defaultLightTheme: 33 + type: ref 34 + ref: "#themeRef" 35 + description: >- 36 + The default theme for light color scheme mode. 37 + defaultDarkTheme: 38 + type: ref 39 + ref: "#themeRef" 40 + description: >- 41 + The default theme for dark color scheme mode. 42 + allowUserChoice: 43 + type: boolean 44 + description: >- 45 + Whether users can select their own theme from the available list. 46 + When false, all users see the forum defaults. 47 + default: true 48 + updatedAt: 49 + type: string 50 + format: datetime 51 + description: >- 52 + Timestamp when this policy was last modified. 53 + themeRef: 54 + type: object 55 + description: >- 56 + A reference to a theme record, wrapped in a named def for semantic 57 + clarity and future extensibility. 58 + required: 59 + - theme 60 + properties: 61 + theme: 62 + type: ref 63 + ref: com.atproto.repo.strongRef 64 + description: >- 65 + Strong reference (AT-URI + CID) to a space.atbb.forum.theme record.