WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

feat(theming): ship built-in preset themes with canonical atbb.space refs (ATB-61) (#93)

* feat(theming): ship built-in preset themes with canonical atbb.space refs (ATB-61)

Redesigns themeRef to use optional CID (live vs pinned refs), ships 5 complete
preset token sets, and adds release pipeline + escape-hatch scripts.

Lexicon:
- themePolicy#themeRef: replace strongRef wrapper with flat { uri, cid? }
uri-only = live ref (auto-updates); uri+cid = pinned ref (version-locked)

DB:
- theme_policy_available_themes.theme_cid: DROP NOT NULL (migration 0014)

Presets:
- Add clean-light.json, clean-dark.json, classic-bb.json (3 new presets)
- All 5 presets bundled as hardcoded fallback + deployment pipeline source

AppView:
- indexer: update themeRef field access (.theme.uri -> .uri, .theme.cid -> .cid)
- admin PUT /api/admin/theme-policy: remove CID-autofill/DB-lookup block;
pass CID through when provided, omit when absent; flat PDS record write
- theme-resolution: cid optional in ThemePolicyResponse type; split warning
for "URI not in availableThemes" vs "absent CID on live ref"

Scripts:
- publish-presets.ts: idempotent release pipeline script; skips unchanged
presets by comparing sorted token JSON; preserves createdAt on updates
- bootstrap-local-presets.ts: escape-hatch for zero-external-deps installs;
writes presets to forum's own PDS, rewrites themePolicy to local URIs

Docs:
- theming-plan.md: document canonical-presets design, live/pinned ref model,
deployment pipeline, local escape hatch, updated resolution waterfall
- ATB-61 Linear issue updated with new scope and acceptance criteria

* fix(atb-61): address PR review feedback on preset theme implementation

- Fix rkey regex to allow hyphens (/^[a-z0-9-]+$/i) — all 5 preset rkeys
contain hyphens (neobrutal-light, clean-dark, etc.) and were silently
falling back to the hardcoded theme
- Add live-ref resolveTheme test verifying CID check is skipped when
expectedCid is null (canonical atbb.space presets ship without CID)
- Add ThemePolicy indexer tests verifying flat .uri field access and
null themeCid for live refs
- Fix bare catch in publish-presets.ts to only swallow 404 (record not
found) and rethrow all other errors
- Add isRecordCurrent() helper including name/colorScheme in change
detection, not just tokens
- Replace non-null assertions on .find() in admin.ts with explicit guards
- Log existing themePolicy before overwriting in bootstrap-local-presets.ts
- Update Bruno Update Theme Policy docs: remove stale CID-lookup comment

* test(css-sanitizer): prove @IMPORT and EXPRESSION() case-insensitive handling

The sanitizer uses .toLowerCase() before comparing atrule/function names, so
uppercase obfuscation variants are already caught. These two tests document
that assumption explicitly so future changes can't silently break it.

* refactor(cli): move theme preset scripts into atbb CLI as theme subcommands

Moves `publish-presets.ts` and `bootstrap-local-presets.ts` from
`apps/appview/scripts/` into the `@atbb/cli` package as
`atbb theme publish-canonical` and `atbb theme bootstrap-local`.

Both commands sit alongside the existing init/category/board commands
and reuse the CLI's ForumAgent auth infrastructure. The appview npm
scripts that wrapped the old scripts are removed.

authored by

Malpercio and committed by
GitHub
61a234b5 fb9491c9

+2701 -170
+1
apps/appview/drizzle/0014_dry_madrox.sql
···
··· 1 + ALTER TABLE "theme_policy_available_themes" ALTER COLUMN "theme_cid" DROP NOT NULL;
+1526
apps/appview/drizzle/meta/0014_snapshot.json
···
··· 1 + { 2 + "id": "904a2091-92b9-4fb5-accf-b30dc9a1e9bf", 3 + "prevId": "50219693-221b-49a6-8363-f9de0bf6afdd", 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": false 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
··· 99 "when": 1772482052223, 100 "tag": "0013_add_theme_tables", 101 "breakpoints": true 102 } 103 ] 104 }
··· 99 "when": 1772482052223, 100 "tag": "0013_add_theme_tables", 101 "breakpoints": true 102 + }, 103 + { 104 + "idx": 14, 105 + "version": "7", 106 + "when": 1772851617906, 107 + "tag": "0014_dry_madrox", 108 + "breakpoints": true 109 } 110 ] 111 }
+134
apps/appview/src/lib/__tests__/indexer.test.ts
··· 1287 }); 1288 1289 1290 describe("Ban enforcement — handleModActionCreate", () => { 1291 it("calls applyBan when a ban mod action is created", async () => { 1292 const mockBanEnforcer = (indexer as any).banEnforcer;
··· 1287 }); 1288 1289 1290 + describe("ThemePolicy Handler", () => { 1291 + /** 1292 + * Creates a tracking DB for themePolicy tests. 1293 + * ThemePolicy's genericCreate path uses afterUpsert, which requires: 1294 + * 1st insert (themePolicies): .values().returning([{id}]) 1295 + * delete (themePolicyAvailableThemes): .where() 1296 + * 2nd insert (themePolicyAvailableThemes): .values() 1297 + */ 1298 + function createThemePolicyTrackingDb() { 1299 + let insertCallCount = 0; 1300 + let policyInsertValues: any = null; 1301 + let availableThemesInsertValues: any = null; 1302 + 1303 + const db = { 1304 + transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise<any>) => { 1305 + const tx = { 1306 + insert: vi.fn().mockImplementation(() => ({ 1307 + values: vi.fn().mockImplementation((vals: any) => { 1308 + insertCallCount++; 1309 + if (insertCallCount === 1) { 1310 + policyInsertValues = vals; 1311 + return { returning: vi.fn().mockResolvedValue([{ id: 1n }]) }; 1312 + } 1313 + availableThemesInsertValues = vals; 1314 + return Promise.resolve(undefined); 1315 + }), 1316 + })), 1317 + delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), 1318 + update: vi.fn(), 1319 + select: vi.fn().mockReturnValue({ 1320 + from: vi.fn().mockReturnValue({ 1321 + where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), 1322 + }), 1323 + }), 1324 + }; 1325 + return await callback(tx); 1326 + }), 1327 + } as unknown as Database; 1328 + 1329 + return { 1330 + db, 1331 + getPolicyInsertValues: () => policyInsertValues, 1332 + getAvailableThemesInsertValues: () => availableThemesInsertValues, 1333 + }; 1334 + } 1335 + 1336 + it("indexes themePolicy with flat themeRef URIs — live refs (no CID)", async () => { 1337 + // This test verifies the field access uses .uri directly (not .theme.uri from old strongRef). 1338 + // If the old .defaultLightTheme.theme.uri path were used, this would throw TypeError. 1339 + const { db: trackingDb, getPolicyInsertValues, getAvailableThemesInsertValues } = 1340 + createThemePolicyTrackingDb(); 1341 + const themePolicyIndexer = new Indexer(trackingDb, mockLogger); 1342 + 1343 + const event: CommitCreateEvent<"space.atbb.forum.themePolicy"> = { 1344 + did: "did:plc:forum", 1345 + time_us: 1234567890, 1346 + kind: "commit", 1347 + commit: { 1348 + rev: "abc", 1349 + operation: "create", 1350 + collection: "space.atbb.forum.themePolicy", 1351 + rkey: "self", 1352 + cid: "cidPolicy", 1353 + record: { 1354 + $type: "space.atbb.forum.themePolicy", 1355 + defaultLightTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" }, 1356 + defaultDarkTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" }, 1357 + availableThemes: [ 1358 + { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" }, 1359 + { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" }, 1360 + ], 1361 + allowUserChoice: true, 1362 + updatedAt: "2026-01-01T00:00:00Z", 1363 + } as any, 1364 + }, 1365 + }; 1366 + 1367 + await expect(themePolicyIndexer.handleThemePolicyCreate(event)).resolves.not.toThrow(); 1368 + 1369 + const policyVals = getPolicyInsertValues(); 1370 + expect(policyVals).toBeDefined(); 1371 + expect(policyVals.defaultLightThemeUri).toBe( 1372 + "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" 1373 + ); 1374 + expect(policyVals.defaultDarkThemeUri).toBe( 1375 + "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" 1376 + ); 1377 + 1378 + const availableVals = getAvailableThemesInsertValues(); 1379 + expect(availableVals).toBeDefined(); 1380 + expect(availableVals[0].themeUri).toBe( 1381 + "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" 1382 + ); 1383 + // Live refs: no CID in record → themeCid must be null in DB row 1384 + expect(availableVals[0].themeCid).toBeNull(); 1385 + }); 1386 + 1387 + it("indexes themePolicy with pinned themeRefs (CID present)", async () => { 1388 + const { db: trackingDb, getAvailableThemesInsertValues } = 1389 + createThemePolicyTrackingDb(); 1390 + const themePolicyIndexer = new Indexer(trackingDb, mockLogger); 1391 + 1392 + const event: CommitCreateEvent<"space.atbb.forum.themePolicy"> = { 1393 + did: "did:plc:forum", 1394 + time_us: 1234567890, 1395 + kind: "commit", 1396 + commit: { 1397 + rev: "abc", 1398 + operation: "create", 1399 + collection: "space.atbb.forum.themePolicy", 1400 + rkey: "self", 1401 + cid: "cidPolicy2", 1402 + record: { 1403 + $type: "space.atbb.forum.themePolicy", 1404 + defaultLightTheme: { uri: "at://did:plc:forum/space.atbb.forum.theme/light1", cid: "bafylight" }, 1405 + defaultDarkTheme: { uri: "at://did:plc:forum/space.atbb.forum.theme/dark1", cid: "bafydark" }, 1406 + availableThemes: [ 1407 + { uri: "at://did:plc:forum/space.atbb.forum.theme/light1", cid: "bafylight" }, 1408 + { uri: "at://did:plc:forum/space.atbb.forum.theme/dark1", cid: "bafydark" }, 1409 + ], 1410 + allowUserChoice: false, 1411 + updatedAt: "2026-01-01T00:00:00Z", 1412 + } as any, 1413 + }, 1414 + }; 1415 + 1416 + await themePolicyIndexer.handleThemePolicyCreate(event); 1417 + 1418 + const availableVals = getAvailableThemesInsertValues(); 1419 + expect(availableVals[0].themeCid).toBe("bafylight"); 1420 + expect(availableVals[1].themeCid).toBe("bafydark"); 1421 + }); 1422 + }); 1423 + 1424 describe("Ban enforcement — handleModActionCreate", () => { 1425 it("calls applyBan when a ban mod action is created", async () => { 1426 const mockBanEnforcer = (indexer as any).banEnforcer;
+6 -6
apps/appview/src/lib/indexer.ts
··· 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 }), ··· 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 }
··· 394 did: event.did, 395 rkey: event.commit.rkey, 396 cid: event.commit.cid, 397 + defaultLightThemeUri: record.defaultLightTheme.uri, 398 + defaultDarkThemeUri: record.defaultDarkTheme.uri, 399 allowUserChoice: record.allowUserChoice, 400 indexedAt: new Date(), 401 }), 402 toUpdateValues: async (event, record) => ({ 403 cid: event.commit.cid, 404 + defaultLightThemeUri: record.defaultLightTheme.uri, 405 + defaultDarkThemeUri: record.defaultDarkTheme.uri, 406 allowUserChoice: record.allowUserChoice, 407 indexedAt: new Date(), 408 }), ··· 417 await tx.insert(themePolicyAvailableThemes).values( 418 available.map((themeRef) => ({ 419 policyId, 420 + themeUri: themeRef.uri, 421 + themeCid: themeRef.cid ?? null, 422 })) 423 ); 424 }
+39 -76
apps/appview/src/routes/__tests__/admin.test.ts
··· 3198 expect(mockPutRecord).toHaveBeenCalledOnce(); 3199 }); 3200 3201 - it("writes PDS record with themeRef wrapper structure", async () => { 3202 await app.request("/api/admin/theme-policy", { 3203 method: "PUT", 3204 headers: { "Content-Type": "application/json" }, ··· 3207 const call = mockPutRecord.mock.calls[0][0]; 3208 expect(call.record.$type).toBe("space.atbb.forum.themePolicy"); 3209 expect(call.rkey).toBe("self"); 3210 - // availableThemes wrapped in { theme: { uri, cid } } 3211 - expect(call.record.availableThemes[0]).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 3212 - expect(call.record.defaultLightTheme).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 3213 - expect(call.record.defaultDarkTheme).toEqual({ theme: { uri: darkUri, cid: "bafydark" } }); 3214 expect(call.record.allowUserChoice).toBe(true); 3215 expect(typeof call.record.updatedAt).toBe("string"); 3216 expect(call.collection).toBe("space.atbb.forum.themePolicy"); ··· 3274 expect(body.error).toMatch(/availableThemes/i); 3275 }); 3276 3277 - it("accepts availableThemes with just uri (no cid) by looking up cid from DB", async () => { 3278 - // Insert a theme so the DB lookup will find it 3279 - const themeUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme1aa`; 3280 - await ctx.db.insert(themes).values({ 3281 - did: ctx.config.forumDid, 3282 - rkey: "3lbltheme1aa", 3283 - cid: "bafytheme1", 3284 - name: "Neobrutal Light", 3285 - colorScheme: "light", 3286 - tokens: {}, 3287 - createdAt: new Date(), 3288 - indexedAt: new Date(), 3289 - }); 3290 3291 const res = await app.request("/api/admin/theme-policy", { 3292 method: "PUT", 3293 headers: { "Content-Type": "application/json" }, 3294 body: JSON.stringify({ 3295 - defaultLightThemeUri: themeUri, 3296 - defaultDarkThemeUri: themeUri, 3297 allowUserChoice: true, 3298 - availableThemes: [{ uri: themeUri }], // no cid 3299 }), 3300 }); 3301 3302 expect(res.status).toBe(200); 3303 expect(mockPutRecord).toHaveBeenCalledOnce(); 3304 const putCall = mockPutRecord.mock.calls[0][0]; 3305 - // The resolved entry should have the CID looked up from the DB 3306 - expect(putCall.record.availableThemes[0]).toEqual({ theme: { uri: themeUri, cid: "bafytheme1" } }); 3307 - expect(putCall.record.defaultLightTheme).toEqual({ theme: { uri: themeUri, cid: "bafytheme1" } }); 3308 - expect(putCall.record.defaultDarkTheme).toEqual({ theme: { uri: themeUri, cid: "bafytheme1" } }); 3309 }); 3310 3311 - it("returns 400 when availableThemes contains a uri-only entry not found in DB", async () => { 3312 - // No theme inserted in DB — URI won't be found 3313 - const unknownUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lblunknown1`; 3314 3315 const res = await app.request("/api/admin/theme-policy", { 3316 method: "PUT", 3317 headers: { "Content-Type": "application/json" }, 3318 body: JSON.stringify({ 3319 - defaultLightThemeUri: unknownUri, 3320 - defaultDarkThemeUri: unknownUri, 3321 allowUserChoice: true, 3322 - availableThemes: [{ uri: unknownUri }], // no cid, not in DB 3323 }), 3324 }); 3325 3326 - expect(res.status).toBe(400); 3327 - const body = await res.json(); 3328 - expect(body.error).toMatch(/unknown theme uri/i); 3329 - expect(mockPutRecord).not.toHaveBeenCalled(); 3330 }); 3331 3332 - it("returns 400 when availableThemes entry has cid: \"\" (empty string is not a valid CID)", async () => { 3333 - // Explicit cid: "" must be treated the same as absent cid — not a valid strongRef CID 3334 - const unknownUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lblunknown2`; 3335 3336 const res = await app.request("/api/admin/theme-policy", { 3337 method: "PUT", 3338 headers: { "Content-Type": "application/json" }, 3339 body: JSON.stringify({ 3340 - defaultLightThemeUri: unknownUri, 3341 - defaultDarkThemeUri: unknownUri, 3342 allowUserChoice: true, 3343 - availableThemes: [{ uri: unknownUri, cid: "" }], // empty string cid, not in DB 3344 }), 3345 }); 3346 3347 - expect(res.status).toBe(400); 3348 - const body = await res.json(); 3349 - expect(body.error).toMatch(/unknown theme uri/i); 3350 - expect(mockPutRecord).not.toHaveBeenCalled(); 3351 }); 3352 3353 - it("returns 500 when DB query fails during uri-only CID lookup", async () => { 3354 - // Force needsLookup = true by omitting cid, then fail the DB select 3355 - const unknownUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lblunknown3`; 3356 - const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 3357 - throw new Error("Database connection lost"); 3358 - }); 3359 - 3360 - const res = await app.request("/api/admin/theme-policy", { 3361 - method: "PUT", 3362 - headers: { "Content-Type": "application/json" }, 3363 - body: JSON.stringify({ 3364 - defaultLightThemeUri: unknownUri, 3365 - defaultDarkThemeUri: unknownUri, 3366 - allowUserChoice: true, 3367 - availableThemes: [{ uri: unknownUri }], // no cid → triggers needsLookup 3368 - }), 3369 - }); 3370 - 3371 - expect(res.status).toBe(500); 3372 - const body = await res.json(); 3373 - expect(body.error).toMatch(/failed to look up theme data/i); 3374 - expect(mockPutRecord).not.toHaveBeenCalled(); 3375 - 3376 - dbSelectSpy.mockRestore(); 3377 - }); 3378 - 3379 - it("uses provided cid when entry already includes one (no DB lookup needed)", async () => { 3380 - // The existing validBody has cid on each entry — should use those directly 3381 await app.request("/api/admin/theme-policy", { 3382 method: "PUT", 3383 headers: { "Content-Type": "application/json" }, 3384 body: JSON.stringify(validBody), 3385 }); 3386 const putCall = mockPutRecord.mock.calls[0][0]; 3387 - // Should use the cids from the request, not from DB 3388 - expect(putCall.record.availableThemes[0]).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 3389 - expect(putCall.record.availableThemes[1]).toEqual({ theme: { uri: darkUri, cid: "bafydark" } }); 3390 }); 3391 3392 it("returns 400 when defaultLightThemeUri is not in availableThemes", async () => {
··· 3198 expect(mockPutRecord).toHaveBeenCalledOnce(); 3199 }); 3200 3201 + it("writes PDS record with flat themeRef structure (no theme: wrapper)", async () => { 3202 await app.request("/api/admin/theme-policy", { 3203 method: "PUT", 3204 headers: { "Content-Type": "application/json" }, ··· 3207 const call = mockPutRecord.mock.calls[0][0]; 3208 expect(call.record.$type).toBe("space.atbb.forum.themePolicy"); 3209 expect(call.rkey).toBe("self"); 3210 + // Flat themeRef: { uri, cid } — no nested theme: {} wrapper 3211 + expect(call.record.availableThemes[0]).toEqual({ uri: lightUri, cid: "bafylight" }); 3212 + expect(call.record.defaultLightTheme).toEqual({ uri: lightUri, cid: "bafylight" }); 3213 + expect(call.record.defaultDarkTheme).toEqual({ uri: darkUri, cid: "bafydark" }); 3214 expect(call.record.allowUserChoice).toBe(true); 3215 expect(typeof call.record.updatedAt).toBe("string"); 3216 expect(call.collection).toBe("space.atbb.forum.themePolicy"); ··· 3274 expect(body.error).toMatch(/availableThemes/i); 3275 }); 3276 3277 + it("accepts uri-only entries as live refs — writes themeRef without cid", async () => { 3278 + // URI without cid is a valid live ref (e.g. canonical atbb.space preset) 3279 + // No DB lookup or insertion required — CID is simply absent in the PDS record 3280 + const liveUri = `at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light`; 3281 3282 const res = await app.request("/api/admin/theme-policy", { 3283 method: "PUT", 3284 headers: { "Content-Type": "application/json" }, 3285 body: JSON.stringify({ 3286 + defaultLightThemeUri: liveUri, 3287 + defaultDarkThemeUri: liveUri, 3288 allowUserChoice: true, 3289 + availableThemes: [{ uri: liveUri }], // no cid — live ref 3290 }), 3291 }); 3292 3293 expect(res.status).toBe(200); 3294 expect(mockPutRecord).toHaveBeenCalledOnce(); 3295 const putCall = mockPutRecord.mock.calls[0][0]; 3296 + // Live ref has uri but no cid field 3297 + expect(putCall.record.availableThemes[0]).toEqual({ uri: liveUri }); 3298 + expect(putCall.record.defaultLightTheme).toEqual({ uri: liveUri }); 3299 + expect(putCall.record.defaultDarkTheme).toEqual({ uri: liveUri }); 3300 }); 3301 3302 + it("accepts live refs to external URIs not in local DB (e.g. atbb.space canonical presets)", async () => { 3303 + // Previously the route rejected URIs not found in the local DB. 3304 + // Now URI-only entries are valid live refs regardless of whether the theme is local. 3305 + const externalUri = `at://did:web:atbb.space/space.atbb.forum.theme/clean-light`; 3306 3307 const res = await app.request("/api/admin/theme-policy", { 3308 method: "PUT", 3309 headers: { "Content-Type": "application/json" }, 3310 body: JSON.stringify({ 3311 + defaultLightThemeUri: externalUri, 3312 + defaultDarkThemeUri: externalUri, 3313 allowUserChoice: true, 3314 + availableThemes: [{ uri: externalUri }], 3315 }), 3316 }); 3317 3318 + expect(res.status).toBe(200); 3319 + expect(mockPutRecord).toHaveBeenCalledOnce(); 3320 }); 3321 3322 + it("treats cid: \"\" as absent and writes a live ref (no cid in PDS record)", async () => { 3323 + const themeUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme2aa`; 3324 3325 const res = await app.request("/api/admin/theme-policy", { 3326 method: "PUT", 3327 headers: { "Content-Type": "application/json" }, 3328 body: JSON.stringify({ 3329 + defaultLightThemeUri: themeUri, 3330 + defaultDarkThemeUri: themeUri, 3331 allowUserChoice: true, 3332 + availableThemes: [{ uri: themeUri, cid: "" }], // empty string → treated as absent 3333 }), 3334 }); 3335 3336 + expect(res.status).toBe(200); 3337 + expect(mockPutRecord).toHaveBeenCalledOnce(); 3338 + const putCall = mockPutRecord.mock.calls[0][0]; 3339 + // Empty string cid is dropped — written as live ref 3340 + expect(putCall.record.availableThemes[0]).toEqual({ uri: themeUri }); 3341 }); 3342 3343 + it("uses provided cid as-is when entry includes one (pinned ref)", async () => { 3344 await app.request("/api/admin/theme-policy", { 3345 method: "PUT", 3346 headers: { "Content-Type": "application/json" }, 3347 body: JSON.stringify(validBody), 3348 }); 3349 const putCall = mockPutRecord.mock.calls[0][0]; 3350 + // Pinned refs include both uri and cid 3351 + expect(putCall.record.availableThemes[0]).toEqual({ uri: lightUri, cid: "bafylight" }); 3352 + expect(putCall.record.availableThemes[1]).toEqual({ uri: darkUri, cid: "bafydark" }); 3353 }); 3354 3355 it("returns 400 when defaultLightThemeUri is not in availableThemes", async () => {
+13 -47
apps/appview/src/routes/admin.ts
··· 1480 1481 const resolvedAllowUserChoice = typeof allowUserChoice === "boolean" ? allowUserChoice : true; 1482 1483 - // Build URI→CID map from DB for entries that don't supply a valid cid. 1484 - // Treat cid: "" the same as absent — empty string is not a valid strongRef CID. 1485 - const isMissingCid = (t: { cid?: string }) => 1486 - typeof t.cid !== "string" || t.cid === ""; 1487 - let uriToCid = new Map<string, string>(); 1488 - const needsLookup = typedAvailableThemes.some(isMissingCid); 1489 - if (needsLookup) { 1490 - try { 1491 - const allThemes = await ctx.db 1492 - .select({ did: themes.did, rkey: themes.rkey, cid: themes.cid }) 1493 - .from(themes) 1494 - .where(eq(themes.did, ctx.config.forumDid)); 1495 - uriToCid = new Map( 1496 - allThemes.map((t) => [ 1497 - `at://${t.did}/space.atbb.forum.theme/${t.rkey}`, 1498 - t.cid, 1499 - ]) 1500 - ); 1501 - } catch (error) { 1502 - if (isProgrammingError(error)) throw error; 1503 - ctx.logger.error("Failed to look up theme CIDs from DB", { 1504 - operation: "PUT /api/admin/theme-policy", 1505 - error: error instanceof Error ? error.message : String(error), 1506 - }); 1507 - return c.json({ error: "Failed to look up theme data. Please try again later." }, 500); 1508 - } 1509 - } 1510 - 1511 - // Resolve CIDs: use provided cid if non-empty, otherwise look up from DB. 1512 - // Reject URIs that can't be resolved — "" is not a valid strongRef CID. 1513 - const unresolvedUris = typedAvailableThemes 1514 - .filter((t) => isMissingCid(t) && !uriToCid.has(t.uri)) 1515 - .map((t) => t.uri); 1516 - 1517 - if (unresolvedUris.length > 0) { 1518 - return c.json( 1519 - { error: `Unknown theme URIs in availableThemes: ${unresolvedUris.join(", ")}` }, 1520 - 400 1521 - ); 1522 - } 1523 - 1524 const resolvedThemes = typedAvailableThemes.map((t) => ({ 1525 uri: t.uri, 1526 - cid: !isMissingCid(t) ? t.cid! : uriToCid.get(t.uri)!, 1527 })); 1528 1529 - const lightTheme = resolvedThemes.find((t) => t.uri === defaultLightThemeUri)!; 1530 - const darkTheme = resolvedThemes.find((t) => t.uri === defaultDarkThemeUri)!; 1531 1532 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/theme-policy"); 1533 if (agentError) return agentError; ··· 1540 record: { 1541 $type: "space.atbb.forum.themePolicy", 1542 availableThemes: resolvedThemes.map((t) => ({ 1543 - theme: { uri: t.uri, cid: t.cid }, 1544 })), 1545 - defaultLightTheme: { theme: { uri: lightTheme.uri, cid: lightTheme.cid } }, 1546 - defaultDarkTheme: { theme: { uri: darkTheme.uri, cid: darkTheme.cid } }, 1547 allowUserChoice: resolvedAllowUserChoice, 1548 updatedAt: new Date().toISOString(), 1549 },
··· 1480 1481 const resolvedAllowUserChoice = typeof allowUserChoice === "boolean" ? allowUserChoice : true; 1482 1483 + // CID is optional — live refs (no cid) are valid for canonical atbb.space presets. 1484 + // Pass cid through when provided; omit it when absent or empty string. 1485 const resolvedThemes = typedAvailableThemes.map((t) => ({ 1486 uri: t.uri, 1487 + cid: typeof t.cid === "string" && t.cid !== "" ? t.cid : undefined, 1488 })); 1489 1490 + const lightTheme = resolvedThemes.find((t) => t.uri === defaultLightThemeUri); 1491 + const darkTheme = resolvedThemes.find((t) => t.uri === defaultDarkThemeUri); 1492 + if (!lightTheme || !darkTheme) { 1493 + // Both URIs were validated as present in availableThemes above — this is unreachable. 1494 + return c.json({ error: "Internal error: theme URIs not found in resolved themes" }, 500); 1495 + } 1496 1497 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/theme-policy"); 1498 if (agentError) return agentError; ··· 1505 record: { 1506 $type: "space.atbb.forum.themePolicy", 1507 availableThemes: resolvedThemes.map((t) => ({ 1508 + uri: t.uri, 1509 + ...(t.cid !== undefined ? { cid: t.cid } : {}), 1510 })), 1511 + defaultLightTheme: { uri: lightTheme.uri, ...(lightTheme.cid !== undefined ? { cid: lightTheme.cid } : {}) }, 1512 + defaultDarkTheme: { uri: darkTheme.uri, ...(darkTheme.cid !== undefined ? { cid: darkTheme.cid } : {}) }, 1513 allowUserChoice: resolvedAllowUserChoice, 1514 updatedAt: new Date().toISOString(), 1515 },
+27 -1
apps/web/src/lib/__tests__/theme-resolution.test.ts
··· 306 }); 307 308 it("returns FALLBACK_THEME when rkey contains path traversal characters", async () => { 309 - // rkey extracted from URI would be "..%2Fsecret" after split — fails /^[a-z0-9]+$/i 310 mockFetch.mockResolvedValueOnce( 311 policyResponse({ 312 defaultLightThemeUri: "at://did/col/../../secret", ··· 316 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 317 // Only the policy fetch should have been made (no theme fetch) 318 expect(mockFetch).toHaveBeenCalledTimes(1); 319 }); 320 });
··· 306 }); 307 308 it("returns FALLBACK_THEME when rkey contains path traversal characters", async () => { 309 + // parseRkeyFromUri("at://did/col/../../secret") splits on "/" and returns parts[4] = ".." 310 + // ".." fails /^[a-z0-9-]+$/i, so we return FALLBACK_THEME without a theme fetch 311 mockFetch.mockResolvedValueOnce( 312 policyResponse({ 313 defaultLightThemeUri: "at://did/col/../../secret", ··· 317 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 318 // Only the policy fetch should have been made (no theme fetch) 319 expect(mockFetch).toHaveBeenCalledTimes(1); 320 + }); 321 + 322 + it("resolves theme from live ref (no CID in policy) without logging CID mismatch", async () => { 323 + // Live refs have no CID — canonical atbb.space presets ship this way. 324 + // The CID integrity check must be skipped when expectedCid is null. 325 + mockFetch 326 + .mockResolvedValueOnce( 327 + policyResponse({ 328 + availableThemes: [ 329 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight" }, // no cid 330 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark" }, // no cid 331 + ], 332 + }) 333 + ) 334 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 335 + 336 + const result = await resolveTheme(APPVIEW, undefined, undefined); 337 + 338 + // Theme resolved successfully — live ref does not trigger CID mismatch 339 + expect(result.tokens["color-bg"]).toBe("#fff"); 340 + expect(result.colorScheme).toBe("light"); 341 + expect(mockLogger.warn).not.toHaveBeenCalledWith( 342 + expect.stringContaining("CID mismatch"), 343 + expect.any(Object) 344 + ); 345 }); 346 });
+6 -5
apps/web/src/lib/theme-resolution.ts
··· 54 defaultLightThemeUri: string | null; 55 defaultDarkThemeUri: string | null; 56 allowUserChoice: boolean; 57 - availableThemes: Array<{ uri: string; cid: string }>; 58 } 59 60 interface ThemeResponse { ··· 121 if (!defaultUri) return { ...FALLBACK_THEME, colorScheme }; 122 123 const rkey = parseRkeyFromUri(defaultUri); 124 - if (!rkey || !/^[a-z0-9]+$/i.test(rkey)) return { ...FALLBACK_THEME, colorScheme }; 125 126 - const expectedCid = 127 - policy.availableThemes.find((t: { uri: string; cid: string }) => t.uri === defaultUri)?.cid ?? null; 128 - if (expectedCid === null) { 129 logger.warn("Theme URI not in availableThemes — skipping CID check", { 130 operation: "resolveTheme", 131 themeUri: defaultUri, 132 }); 133 } 134 135 // ── Step 4: Fetch theme ──────────────────────────────────────────────────── 136 let themeRes: Response;
··· 54 defaultLightThemeUri: string | null; 55 defaultDarkThemeUri: string | null; 56 allowUserChoice: boolean; 57 + availableThemes: Array<{ uri: string; cid?: string }>; 58 } 59 60 interface ThemeResponse { ··· 121 if (!defaultUri) return { ...FALLBACK_THEME, colorScheme }; 122 123 const rkey = parseRkeyFromUri(defaultUri); 124 + if (!rkey || !/^[a-z0-9-]+$/i.test(rkey)) return { ...FALLBACK_THEME, colorScheme }; 125 126 + const matchingTheme = policy.availableThemes.find((t) => t.uri === defaultUri); 127 + if (!matchingTheme) { 128 logger.warn("Theme URI not in availableThemes — skipping CID check", { 129 operation: "resolveTheme", 130 themeUri: defaultUri, 131 }); 132 } 133 + // cid may be absent for live refs (e.g. canonical atbb.space presets) — that is expected 134 + const expectedCid = matchingTheme?.cid ?? null; 135 136 // ── Step 4: Fetch theme ──────────────────────────────────────────────────── 137 let themeRes: Response;
+8 -2
apps/web/src/routes/admin-themes.tsx
··· 11 import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 12 import neobrutalLight from "../styles/presets/neobrutal-light.json"; 13 import neobrutalDark from "../styles/presets/neobrutal-dark.json"; 14 15 // ─── Types ───────────────────────────────────────────────────────────────── 16 ··· 30 defaultLightThemeUri: string | null; 31 defaultDarkThemeUri: string | null; 32 allowUserChoice: boolean; 33 - availableThemes: Array<{ uri: string; cid: string }>; 34 } 35 36 // Preset token maps — used by POST /admin/themes to seed tokens on creation 37 const THEME_PRESETS: Record<string, Record<string, string>> = { 38 "neobrutal-light": neobrutalLight as Record<string, string>, 39 - "neobrutal-dark": neobrutalDark as Record<string, string>, 40 "blank": {}, 41 }; 42
··· 11 import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 12 import neobrutalLight from "../styles/presets/neobrutal-light.json"; 13 import neobrutalDark from "../styles/presets/neobrutal-dark.json"; 14 + import cleanLight from "../styles/presets/clean-light.json"; 15 + import cleanDark from "../styles/presets/clean-dark.json"; 16 + import classicBb from "../styles/presets/classic-bb.json"; 17 18 // ─── Types ───────────────────────────────────────────────────────────────── 19 ··· 33 defaultLightThemeUri: string | null; 34 defaultDarkThemeUri: string | null; 35 allowUserChoice: boolean; 36 + availableThemes: Array<{ uri: string; cid?: string }>; 37 } 38 39 // Preset token maps — used by POST /admin/themes to seed tokens on creation 40 const THEME_PRESETS: Record<string, Record<string, string>> = { 41 "neobrutal-light": neobrutalLight as Record<string, string>, 42 + "neobrutal-dark": neobrutalDark as Record<string, string>, 43 + "clean-light": cleanLight as Record<string, string>, 44 + "clean-dark": cleanDark as Record<string, string>, 45 + "classic-bb": classicBb as Record<string, string>, 46 "blank": {}, 47 }; 48
+73
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 = [ ··· 58 expect(css).toContain("--font-size-xs:"); 59 }); 60 });
··· 1 import { describe, it, expect } from "vitest"; 2 import neobrutalLight from "../neobrutal-light.json"; 3 import neobrutalDark from "../neobrutal-dark.json"; 4 + import cleanLight from "../clean-light.json"; 5 + import cleanDark from "../clean-dark.json"; 6 + import classicBb from "../classic-bb.json"; 7 import { tokensToCss } from "../../../lib/theme.js"; 8 9 const REQUIRED_TOKENS = [ ··· 61 expect(css).toContain("--font-size-xs:"); 62 }); 63 }); 64 + 65 + describe("clean-light preset", () => { 66 + it("contains all required tokens", () => { 67 + for (const token of REQUIRED_TOKENS) { 68 + expect(cleanLight, `missing token: ${token}`).toHaveProperty(token); 69 + } 70 + }); 71 + 72 + it("has rounded corners (radius > 0)", () => { 73 + const radius = (cleanLight as Record<string, string>)["radius"]; 74 + expect(radius).not.toBe("0px"); 75 + }); 76 + 77 + it("has a different background color than neobrutal-light", () => { 78 + expect((cleanLight as Record<string, string>)["color-bg"]).not.toBe( 79 + (neobrutalLight as Record<string, string>)["color-bg"] 80 + ); 81 + }); 82 + 83 + it("produces valid CSS via tokensToCss", () => { 84 + const css = tokensToCss(cleanLight as Record<string, string>); 85 + expect(css).toContain("--color-bg:"); 86 + expect(css).toContain("--radius:"); 87 + }); 88 + }); 89 + 90 + describe("clean-dark preset", () => { 91 + it("contains all required tokens", () => { 92 + for (const token of REQUIRED_TOKENS) { 93 + expect(cleanDark, `missing token: ${token}`).toHaveProperty(token); 94 + } 95 + }); 96 + 97 + it("has a darker background than clean-light", () => { 98 + expect((cleanDark as Record<string, string>)["color-bg"]).not.toBe( 99 + (cleanLight as Record<string, string>)["color-bg"] 100 + ); 101 + }); 102 + 103 + it("produces valid CSS via tokensToCss", () => { 104 + const css = tokensToCss(cleanDark as Record<string, string>); 105 + expect(css).toContain("--color-bg:"); 106 + expect(css).toContain("--card-shadow:"); 107 + }); 108 + }); 109 + 110 + describe("classic-bb preset", () => { 111 + it("contains all required tokens", () => { 112 + for (const token of REQUIRED_TOKENS) { 113 + expect(classicBb, `missing token: ${token}`).toHaveProperty(token); 114 + } 115 + }); 116 + 117 + it("uses a smaller base font size than modern presets", () => { 118 + const classicBase = parseInt((classicBb as Record<string, string>)["font-size-base"]); 119 + const neobrutalBase = parseInt((neobrutalLight as Record<string, string>)["font-size-base"]); 120 + expect(classicBase).toBeLessThan(neobrutalBase); 121 + }); 122 + 123 + it("uses a serif-era font stack (Verdana)", () => { 124 + const fontBody = (classicBb as Record<string, string>)["font-body"]; 125 + expect(fontBody).toContain("Verdana"); 126 + }); 127 + 128 + it("produces valid CSS via tokensToCss", () => { 129 + const css = tokensToCss(classicBb as Record<string, string>); 130 + expect(css).toContain("--color-bg:"); 131 + expect(css).toContain("--font-size-base:"); 132 + }); 133 + });
+47
apps/web/src/styles/presets/classic-bb.json
···
··· 1 + { 2 + "color-bg": "#efeff0", 3 + "color-surface": "#ffffff", 4 + "color-text": "#333333", 5 + "color-text-muted": "#666666", 6 + "color-primary": "#336699", 7 + "color-primary-hover": "#224477", 8 + "color-secondary": "#5566aa", 9 + "color-border": "#bbbbbb", 10 + "color-shadow": "#999999", 11 + "color-success": "#336600", 12 + "color-warning": "#cc6600", 13 + "color-danger": "#cc0000", 14 + "color-code-bg": "#f4f4f4", 15 + "color-code-text": "#333333", 16 + "font-body": "Verdana, Arial, Helvetica, sans-serif", 17 + "font-heading": "Verdana, Arial, Helvetica, sans-serif", 18 + "font-mono": "'Courier New', Courier, monospace", 19 + "font-size-base": "13px", 20 + "font-size-sm": "11px", 21 + "font-size-xs": "10px", 22 + "font-size-lg": "15px", 23 + "font-size-xl": "18px", 24 + "font-size-2xl": "24px", 25 + "font-weight-normal": "400", 26 + "font-weight-bold": "700", 27 + "line-height-body": "1.5", 28 + "line-height-heading": "1.2", 29 + "space-xs": "3px", 30 + "space-sm": "6px", 31 + "space-md": "12px", 32 + "space-lg": "18px", 33 + "space-xl": "30px", 34 + "radius": "3px", 35 + "border-width": "1px", 36 + "shadow-offset": "1px", 37 + "content-width": "100%", 38 + "button-radius": "3px", 39 + "button-shadow": "1px 1px 1px var(--color-shadow)", 40 + "card-radius": "3px", 41 + "card-shadow": "1px 1px 2px var(--color-shadow)", 42 + "btn-press-hover": "0px", 43 + "btn-press-active": "1px", 44 + "input-radius": "2px", 45 + "input-border": "1px solid #bbbbbb", 46 + "nav-height": "50px" 47 + }
+47
apps/web/src/styles/presets/clean-dark.json
···
··· 1 + { 2 + "color-bg": "#0f172a", 3 + "color-surface": "#1e293b", 4 + "color-text": "#f1f5f9", 5 + "color-text-muted": "#94a3b8", 6 + "color-primary": "#60a5fa", 7 + "color-primary-hover": "#3b82f6", 8 + "color-secondary": "#a78bfa", 9 + "color-border": "#334155", 10 + "color-shadow": "rgba(0,0,0,0.4)", 11 + "color-success": "#4ade80", 12 + "color-warning": "#fbbf24", 13 + "color-danger": "#f87171", 14 + "color-code-bg": "#0d1117", 15 + "color-code-text": "#e2e8f0", 16 + "font-body": "system-ui, -apple-system, sans-serif", 17 + "font-heading": "system-ui, -apple-system, sans-serif", 18 + "font-mono": "ui-monospace, 'Cascadia Code', 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": "24px", 24 + "font-size-2xl": "32px", 25 + "font-weight-normal": "400", 26 + "font-weight-bold": "600", 27 + "line-height-body": "1.6", 28 + "line-height-heading": "1.25", 29 + "space-xs": "4px", 30 + "space-sm": "8px", 31 + "space-md": "16px", 32 + "space-lg": "24px", 33 + "space-xl": "40px", 34 + "radius": "8px", 35 + "border-width": "1px", 36 + "shadow-offset": "0px", 37 + "content-width": "100%", 38 + "button-radius": "6px", 39 + "button-shadow": "0 1px 2px rgba(0,0,0,0.2)", 40 + "card-radius": "8px", 41 + "card-shadow": "0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2)", 42 + "btn-press-hover": "0px", 43 + "btn-press-active": "0px", 44 + "input-radius": "6px", 45 + "input-border": "1px solid #334155", 46 + "nav-height": "64px" 47 + }
+47
apps/web/src/styles/presets/clean-light.json
···
··· 1 + { 2 + "color-bg": "#f9fafb", 3 + "color-surface": "#ffffff", 4 + "color-text": "#111827", 5 + "color-text-muted": "#6b7280", 6 + "color-primary": "#2563eb", 7 + "color-primary-hover": "#1d4ed8", 8 + "color-secondary": "#7c3aed", 9 + "color-border": "#e5e7eb", 10 + "color-shadow": "rgba(0,0,0,0.08)", 11 + "color-success": "#16a34a", 12 + "color-warning": "#d97706", 13 + "color-danger": "#dc2626", 14 + "color-code-bg": "#f3f4f6", 15 + "color-code-text": "#1f2937", 16 + "font-body": "system-ui, -apple-system, sans-serif", 17 + "font-heading": "system-ui, -apple-system, sans-serif", 18 + "font-mono": "ui-monospace, 'Cascadia Code', 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": "24px", 24 + "font-size-2xl": "32px", 25 + "font-weight-normal": "400", 26 + "font-weight-bold": "600", 27 + "line-height-body": "1.6", 28 + "line-height-heading": "1.25", 29 + "space-xs": "4px", 30 + "space-sm": "8px", 31 + "space-md": "16px", 32 + "space-lg": "24px", 33 + "space-xl": "40px", 34 + "radius": "8px", 35 + "border-width": "1px", 36 + "shadow-offset": "0px", 37 + "content-width": "100%", 38 + "button-radius": "6px", 39 + "button-shadow": "0 1px 2px rgba(0,0,0,0.05)", 40 + "card-radius": "8px", 41 + "card-shadow": "0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04)", 42 + "btn-press-hover": "0px", 43 + "btn-press-active": "0px", 44 + "input-radius": "6px", 45 + "input-border": "1px solid #e5e7eb", 46 + "nav-height": "64px" 47 + }
+2 -1
bruno/AppView API/Admin Themes/Update Theme Policy.bru
··· 34 35 Body: 36 - availableThemes (required): Non-empty array of { uri, cid? } theme references. 37 - cid is optional — if omitted, the AppView looks it up from the themes table by URI. 38 Both defaultLightThemeUri and defaultDarkThemeUri must be present in this list. 39 - defaultLightThemeUri (required): AT-URI of the default light-mode theme. 40 Must be in availableThemes.
··· 34 35 Body: 36 - availableThemes (required): Non-empty array of { uri, cid? } theme references. 37 + cid is optional — if omitted, this is a live ref that always resolves to the current 38 + version of the theme record. Canonical atbb.space presets use live refs by default. 39 Both defaultLightThemeUri and defaultDarkThemeUri must be present in this list. 40 - defaultLightThemeUri (required): AT-URI of the default light-mode theme. 41 Must be in availableThemes.
+294
docs/plans/2026-03-07-atb61-preset-themes-implementation.md
···
··· 1 + # ATB-61: Built-in Preset Themes — Implementation Plan 2 + 3 + **Branch:** `root/atb-61-ship-built-in-theme-presets` 4 + **Design doc:** `docs/theming-plan.md` — Built-in Preset Themes section 5 + **Linear:** ATB-61 6 + 7 + --- 8 + 9 + ## Overview 10 + 11 + Built-in presets are published as canonical records on `atbb.space`'s PDS. Fresh installs reference them via live (URI-only) `themeRef`s. This requires: 12 + 13 + 1. A lexicon change (`themeRef.cid` → optional) with a cascading TypeScript fix across 4 files 14 + 2. A DB migration (`themeCid` → nullable) 15 + 3. Three new preset JSON files + tests 16 + 4. A release pipeline script to publish presets to `atbb.space` 17 + 5. A `--local-presets` bootstrap escape hatch 18 + 19 + --- 20 + 21 + ## Phase 1: Lexicon + DB migration + TypeScript cascade 22 + 23 + ### 1.1 — Update `themePolicy.yaml` 24 + 25 + **File:** `packages/lexicon/lexicons/space/atbb/forum/themePolicy.yaml` 26 + 27 + Replace the `themeRef` def. Remove the `com.atproto.repo.strongRef` wrapper; make `cid` optional: 28 + 29 + ```yaml 30 + themeRef: 31 + type: object 32 + description: >- 33 + A reference to a theme record. When 'cid' is present the reference is 34 + pinned — the appview verifies CID on fetch and falls through on mismatch. 35 + When 'cid' is absent the reference is live — always resolves the current 36 + record at the URI (used by default for canonical atbb.space presets). 37 + required: 38 + - uri 39 + properties: 40 + uri: 41 + type: string 42 + format: at-uri 43 + description: AT-URI of the space.atbb.forum.theme record. 44 + cid: 45 + type: string 46 + format: cid 47 + description: >- 48 + Optional CID. When set, the appview pins to this exact record version. 49 + When absent, always resolves the current record at the URI. 50 + ``` 51 + 52 + Then rebuild: `pnpm --filter @atbb/lexicon build` 53 + 54 + ### 1.2 — DB schema: make `themeCid` nullable 55 + 56 + **Files:** 57 + - `packages/db/src/schema.ts` — `theme_policy_available_themes.themeCid`: `.notNull()` → nullable 58 + - `packages/db/src/schema.sqlite.ts` — same change 59 + 60 + Run to generate migration: `pnpm --filter @atbb/appview db:generate` 61 + Run to apply: `pnpm --filter @atbb/appview db:migrate` 62 + 63 + ### 1.3 — Fix `indexer.ts` 64 + 65 + **File:** `apps/appview/src/lib/indexer.ts` 66 + 67 + The `themePolicyConfig` accesses `themeRef.theme.uri` and `.theme.cid` via the old nested wrapper. After the lexicon change these become `themeRef.uri` and `themeRef.cid`. 68 + 69 + ```typescript 70 + // Before 71 + defaultLightThemeUri: record.defaultLightTheme.theme.uri, 72 + defaultDarkThemeUri: record.defaultDarkTheme.theme.uri, 73 + // ... 74 + themeUri: themeRef.theme.uri, 75 + themeCid: themeRef.theme.cid, 76 + 77 + // After 78 + defaultLightThemeUri: record.defaultLightTheme.uri, 79 + defaultDarkThemeUri: record.defaultDarkTheme.uri, 80 + // ... 81 + themeUri: themeRef.uri, 82 + themeCid: themeRef.cid ?? null, 83 + ``` 84 + 85 + ### 1.4 — Simplify `admin.ts` PUT /api/admin/theme-policy 86 + 87 + **File:** `apps/appview/src/routes/admin.ts` 88 + 89 + The existing route auto-fills missing CIDs from the local DB and rejects URIs not found locally. This must be removed — atbb.space URIs won't be in the local DB, and CID is now genuinely optional (live refs). 90 + 91 + **Remove the entire CID-fill block** (roughly lines 1483–1527): 92 + - `isMissingCid`, `uriToCid`, `needsLookup`, `unresolvedUris`, `uriToCid` DB lookup, rejection check 93 + 94 + **Replace `resolvedThemes` map** with a passthrough: 95 + ```typescript 96 + const resolvedThemes = typedAvailableThemes.map((t) => ({ 97 + uri: t.uri, 98 + cid: typeof t.cid === "string" && t.cid !== "" ? t.cid : undefined, 99 + })); 100 + ``` 101 + 102 + **Update PDS record write** to use flat `themeRef` (no `theme:` wrapper): 103 + ```typescript 104 + availableThemes: resolvedThemes.map((t) => ({ 105 + uri: t.uri, 106 + ...(t.cid !== undefined ? { cid: t.cid } : {}), 107 + })), 108 + defaultLightTheme: { uri: lightTheme.uri, ...(lightTheme.cid !== undefined ? { cid: lightTheme.cid } : {}) }, 109 + defaultDarkTheme: { uri: darkTheme.uri, ...(darkTheme.cid !== undefined ? { cid: darkTheme.cid } : {}) }, 110 + ``` 111 + 112 + ### 1.5 — Fix `theme-resolution.ts` 113 + 114 + **File:** `apps/web/src/lib/theme-resolution.ts` 115 + 116 + Update `ThemePolicyResponse.availableThemes` — `cid` is now optional: 117 + ```typescript 118 + availableThemes: Array<{ uri: string; cid?: string }>; 119 + ``` 120 + 121 + Update the CID lookup and check: 122 + ```typescript 123 + const expectedCid = 124 + policy.availableThemes.find((t) => t.uri === defaultUri)?.cid ?? null; 125 + // expectedCid === null means live ref — skip CID check (already does this, just update the type) 126 + ``` 127 + 128 + Also update `admin-themes.tsx` which has the same `ThemePolicy` type locally (line 33): 129 + ```typescript 130 + availableThemes: Array<{ uri: string; cid?: string }>; 131 + ``` 132 + 133 + ### 1.6 — Update `admin.test.ts` 134 + 135 + **File:** `apps/appview/src/routes/__tests__/admin.test.ts` 136 + 137 + Update the "writes PDS record with themeRef wrapper structure" test (line 3201): 138 + - Rename to "writes PDS record with flat themeRef structure" 139 + - Change assertions: `{ uri: lightUri, cid: "bafylight" }` (no `theme:` wrapper) 140 + - Add a test: "accepts themeRef without cid (live ref)" — submit a body with no `cid` fields and verify the PDS record has `themeRef`s without `cid` 141 + 142 + ### 1.7 — Verify Phase 1 143 + 144 + ```sh 145 + pnpm --filter @atbb/lexicon build 146 + pnpm --filter @atbb/appview test 147 + pnpm --filter @atbb/web test 148 + ``` 149 + 150 + --- 151 + 152 + ## Phase 2: Remaining 3 preset JSON files 153 + 154 + ### 2.1 — Design token values 155 + 156 + **Files to create:** 157 + - `apps/web/src/styles/presets/clean-light.json` 158 + - `apps/web/src/styles/presets/clean-dark.json` 159 + - `apps/web/src/styles/presets/classic-bb.json` 160 + 161 + Each must contain all tokens from `REQUIRED_TOKENS` in `presets.test.ts`. 162 + 163 + **Clean Light** — minimal, airy, soft: 164 + - Rounded corners (`radius: "6px"`) 165 + - Thinner borders (`border-width: "1px"`) 166 + - Soft shadows (`card-shadow: "0 2px 8px rgba(0,0,0,0.08)"`) 167 + - Neutral palette (white bg, near-black text, blue primary) 168 + 169 + **Clean Dark** — same proportions as Clean Light on dark surfaces: 170 + - Dark bg (`#1a1a2e` or similar), elevated surface (`#16213e`) 171 + - Muted borders, soft glow shadows 172 + 173 + **Classic BB** — phpBB/vBulletin nostalgia: 174 + - Blue header palette (`color-primary: "#336699"`) 175 + - Gray surfaces (`color-bg: "#efeff0"`, `color-surface: "#ffffff"`) 176 + - Smaller base font (`font-size-base: "13px"`) 177 + - Slight radius (`radius: "3px"`) 178 + - Thin borders (`border-width: "1px"`) 179 + 180 + ### 2.2 — Update preset tests 181 + 182 + **File:** `apps/web/src/styles/presets/__tests__/presets.test.ts` 183 + 184 + Add `describe` blocks for `clean-light`, `clean-dark`, and `classic-bb` matching the existing neobrutal test patterns. 185 + 186 + ### 2.3 — Update admin-themes.tsx THEME_PRESETS 187 + 188 + **File:** `apps/web/src/routes/admin-themes.tsx` 189 + 190 + Import new presets and add to `THEME_PRESETS` map: 191 + ```typescript 192 + import cleanLight from "../styles/presets/clean-light.json"; 193 + import cleanDark from "../styles/presets/clean-dark.json"; 194 + import classicBb from "../styles/presets/classic-bb.json"; 195 + 196 + const THEME_PRESETS: Record<string, Record<string, string>> = { 197 + "neobrutal-light": neobrutalLight as Record<string, string>, 198 + "neobrutal-dark": neobrutalDark as Record<string, string>, 199 + "clean-light": cleanLight as Record<string, string>, 200 + "clean-dark": cleanDark as Record<string, string>, 201 + "classic-bb": classicBb as Record<string, string>, 202 + "blank": {}, 203 + }; 204 + ``` 205 + 206 + ### 2.4 — Verify Phase 2 207 + 208 + ```sh 209 + pnpm --filter @atbb/web test 210 + ``` 211 + 212 + --- 213 + 214 + ## Phase 3: Deployment pipeline script 215 + 216 + ### 3.1 — Write `scripts/publish-presets.ts` 217 + 218 + **File:** `apps/appview/scripts/publish-presets.ts` 219 + 220 + Script that publishes all 5 preset JSON files to `atbb.space`'s PDS as `space.atbb.forum.theme` records under stable rkeys. 221 + 222 + Key behaviors: 223 + - Reads preset files from `../../web/src/styles/presets/` 224 + - Connects to atbb.space PDS using `FORUM_DID` + `FORUM_DID_KEY` env vars 225 + - Uses `ForumAgent` (or direct `AtpAgent`) to `putRecord` each preset 226 + - Rkeys: `neobrutal-light`, `neobrutal-dark`, `clean-light`, `clean-dark`, `classic-bb` 227 + - Idempotent: fetches existing record, compares token content, skips if unchanged 228 + - Dry-run mode: `--dry-run` flag prints what would change without writing 229 + 230 + Add to `package.json`: 231 + ```json 232 + "publish-presets": "tsx --env-file=../../.env scripts/publish-presets.ts" 233 + ``` 234 + 235 + ### 3.2 — Default themePolicy initialization 236 + 237 + Add a `defaultThemePolicy` helper (or extend the bootstrap script) that writes a `themePolicy` record with live refs to atbb.space when setting up a fresh forum. This runs as part of forum initialization, not as a periodic job. 238 + 239 + The default policy: 240 + ```typescript 241 + { 242 + $type: "space.atbb.forum.themePolicy", 243 + availableThemes: [ 244 + { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" }, 245 + { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" }, 246 + { uri: "at://did:web:atbb.space/space.atbb.forum.theme/clean-light" }, 247 + { uri: "at://did:web:atbb.space/space.atbb.forum.theme/clean-dark" }, 248 + { uri: "at://did:web:atbb.space/space.atbb.forum.theme/classic-bb" }, 249 + ], 250 + defaultLightTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" }, 251 + defaultDarkTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" }, 252 + allowUserChoice: true, 253 + updatedAt: new Date().toISOString(), 254 + } 255 + ``` 256 + 257 + --- 258 + 259 + ## Phase 4: Local escape hatch 260 + 261 + ### 4.1 — Write `scripts/bootstrap-local-presets.ts` 262 + 263 + **File:** `apps/appview/scripts/bootstrap-local-presets.ts` 264 + 265 + Command that mirrors canonical presets onto the forum's own PDS and rewrites the themePolicy to point locally. 266 + 267 + Steps: 268 + 1. Read preset JSON files 269 + 2. `putRecord` each to `FORUM_DID`'s PDS under same stable rkeys 270 + 3. `putRecord` the `themePolicy` singleton pointing at `at://FORUM_DID/space.atbb.forum.theme/*` 271 + 272 + Add to `package.json`: 273 + ```json 274 + "bootstrap-local-presets": "tsx --env-file=../../.env scripts/bootstrap-local-presets.ts" 275 + ``` 276 + 277 + Document in `CONTRIBUTING.md` and the deployment guide. 278 + 279 + --- 280 + 281 + ## Verification Checklist (all phases) 282 + 283 + ```sh 284 + pnpm --filter @atbb/lexicon build # Lexicon + TS types regenerated 285 + pnpm --filter @atbb/appview test # All appview tests pass 286 + pnpm --filter @atbb/web test # All web tests pass (including 3 new presets) 287 + pnpm test # Full suite via turbo 288 + ``` 289 + 290 + Manual: 291 + - [ ] Admin theme policy UI: submit with no `cid` fields — verify PDS record has URI-only themeRefs 292 + - [ ] Resolution waterfall: live ref fetches current theme; CID mismatch falls through to fallback 293 + - [ ] "Start from preset" dropdown populates all 5 presets in create form 294 + - [ ] `publish-presets --dry-run` correctly diffs changed vs unchanged presets
+72 -23
docs/theming-plan.md
··· 200 201 ### New: `space.atbb.forum.themePolicy` 202 203 - A new singleton record on the Forum DID for theme configuration, separate from the main forum record to allow independent updates without invalidating `strongRef`s to the forum record. 204 205 ```yaml 206 lexiconId: space.atbb.forum.themePolicy ··· 210 defs: 211 themeRef: 212 type: object 213 - required: [theme] 214 properties: 215 - theme: 216 - type: ref 217 - ref: com.atproto.repo.strongRef # CID integrity check for theme records 218 219 fields: 220 availableThemes: # Themes admins have enabled for users ··· 235 ``` 236 237 **Record ownership:** Forum DID. 238 239 The admin's saved themes may outnumber the available list — `availableThemes` is the curated subset exposed to users. Both `defaultLightTheme` and `defaultDarkTheme` must be members of `availableThemes`. 240 ··· 320 321 ## Built-in Preset Themes 322 323 - Ship with a small set of presets that admins can use as starting points. Each preset ships with both a light and dark variant so forums have sensible defaults for both modes out of the box. 324 325 - | Preset | Color Scheme | Description | 326 - |--------|-------------|-------------| 327 - | **Neobrutal Light** (default light) | light | Bold borders, solid shadows, warm off-white palette, sharp corners | 328 - | **Neobrutal Dark** (default dark) | dark | Same bold/chunky aesthetic on a dark background, muted shadows | 329 - | **Clean Light** | light | Minimal, airy, subtle shadows, rounded corners, neutral palette | 330 - | **Clean Dark** | dark | Soft dark surfaces, gentle borders, same airy spacing | 331 - | **Classic BB** | light | Nostalgic phpBB/vBulletin feel — blue headers, gray panels, small type | 332 333 Each preset is a complete set of token values. Admins pick one as a starting point, then customize from there. A fresh forum defaults to Neobrutal Light + Neobrutal Dark with user choice enabled. 334 335 --- 336 337 ## CSS Architecture ··· 344 reset.css # Minimal normalize/reset 345 theme.css # All component styles using var(--token) references 346 presets/ 347 - neobrutal-light.json # Token values for neobrutal light preset 348 - neobrutal-dark.json # Token values for neobrutal dark preset 349 - clean-light.json # Token values for clean light preset 350 - clean-dark.json # Token values for clean dark preset 351 - classic.json # Token values for classic BB preset 352 ``` 353 354 ### Base Stylesheet Approach 355 ··· 447 AND has a preferredTheme set on their membership record? 448 AND does the forum's themePolicy.allowUserChoice == true? 449 AND is preferredTheme.uri still in themePolicy.availableThemes? 450 - AND does preferredTheme.cid match current theme record (integrity check)? 451 → Use their preferred theme. 452 453 2. Color scheme default ··· 456 b. HTTP header: Sec-CH-Prefers-Color-Scheme (client hint) 457 c. Default: light 458 459 - → Use themePolicy.defaultDarkTheme or defaultLightTheme accordingly 460 - (with CID integrity check via strongRef). 461 462 3. Hardcoded fallback 463 - If no theme policy exists or the resolved theme can't be loaded: 464 - → Use the built-in neobrutal token values (no PDS needed, works offline). 465 ``` 466 467 This is entirely **server-side** — no client JS framework needed. The web server resolves the theme before rendering and bakes the correct tokens into the HTML response.
··· 200 201 ### New: `space.atbb.forum.themePolicy` 202 203 + A new singleton record on the Forum DID for theme configuration, separate from the main forum record to allow independent updates without invalidating references to the forum record. 204 205 ```yaml 206 lexiconId: space.atbb.forum.themePolicy ··· 210 defs: 211 themeRef: 212 type: object 213 + required: [uri] 214 properties: 215 + uri: 216 + type: string 217 + format: at-uri # AT-URI of the space.atbb.forum.theme record 218 + cid: 219 + type: string 220 + format: cid # Optional. When present: pins to exact version (strong). 221 + # When absent: resolves live current record (weak). 222 223 fields: 224 availableThemes: # Themes admins have enabled for users ··· 239 ``` 240 241 **Record ownership:** Forum DID. 242 + 243 + The `cid` field is intentionally optional. The default `themePolicy` shipped by atBB uses URI-only references to canonical preset records on `atbb.space` — forums pick up preset updates automatically after cache expiry. Operators who want version stability can pin a theme to its current CID via the admin UI ("Pin to version"). The appview validates CID on fetch when present; a mismatch falls through to the next step in the resolution waterfall. 244 + 245 + | Reference type | `themeRef` shape | Behavior | 246 + |---|---|---| 247 + | Live | `{ uri: "at://atbb.space/..." }` | Auto-updates when atbb.space publishes new preset versions | 248 + | Pinned | `{ uri: "at://atbb.space/...", cid: "bafyrei..." }` | Locked to exact version; admin sees warning when a newer version exists | 249 + | Local copy | `{ uri: "at://forum-did/..." }` | Full autonomy, no external dependency | 250 251 The admin's saved themes may outnumber the available list — `availableThemes` is the curated subset exposed to users. Both `defaultLightTheme` and `defaultDarkTheme` must be members of `availableThemes`. 252 ··· 332 333 ## Built-in Preset Themes 334 335 + Built-in presets ship as **canonical records on the `atbb.space` PDS**, published and maintained by the atBB project. Fresh installations reference these canonical records in their default `themePolicy` using live (URI-only) `themeRef`s — no local seeding step required. 336 337 + | Preset | Color Scheme | Canonical rkey | 338 + |--------|-------------|----------------| 339 + | **Neobrutal Light** (default light) | light | `neobrutal-light` | 340 + | **Neobrutal Dark** (default dark) | dark | `neobrutal-dark` | 341 + | **Clean Light** | light | `clean-light` | 342 + | **Clean Dark** | dark | `clean-dark` | 343 + | **Classic BB** | light | `classic-bb` | 344 + 345 + Canonical URIs follow the pattern: `at://did:web:atbb.space/space.atbb.forum.theme/<rkey>` 346 347 Each preset is a complete set of token values. Admins pick one as a starting point, then customize from there. A fresh forum defaults to Neobrutal Light + Neobrutal Dark with user choice enabled. 348 349 + The bundled JSON files in `apps/web/src/styles/presets/` serve two purposes: 350 + 1. **Hardcoded fallback** — used directly if no theme policy can be resolved (no network, no PDS write yet). 351 + 2. **Deployment pipeline source** — the release script reads these files and writes them to `atbb.space`'s PDS. 352 + 353 + ### Deployment Pipeline 354 + 355 + When atBB releases a new version with updated preset tokens, a release script: 356 + 357 + 1. Reads the bundled preset JSON files (canonical source of truth in the app repo) 358 + 2. Writes them to `atbb.space`'s PDS under stable rkeys using the Forum DID's signing keys (same URI every release; new CID only when content changes) 359 + 3. Skips records where token values are unchanged (idempotent) 360 + 361 + Forums using live (URI-only) references pick up changes after cache expiry. Forums using pinned (URI+CID) references remain on the locked version until the operator updates. 362 + 363 + ### Local Escape Hatch 364 + 365 + Operators who want zero external dependencies run: 366 + 367 + ```sh 368 + atbb bootstrap --local-presets 369 + ``` 370 + 371 + This command: 372 + 373 + 1. Reads the bundled preset JSON files 374 + 2. Writes them to the forum's own PDS under the same stable rkeys 375 + 3. Updates `themePolicy` to point at the local URIs instead of `atbb.space` 376 + 377 + After this, the forum is fully self-contained. The admin can still customize individual presets via the theme editor. 378 + 379 --- 380 381 ## CSS Architecture ··· 388 reset.css # Minimal normalize/reset 389 theme.css # All component styles using var(--token) references 390 presets/ 391 + neobrutal-light.json # Hardcoded fallback + deployment pipeline source 392 + neobrutal-dark.json # Hardcoded fallback + deployment pipeline source 393 + clean-light.json # Hardcoded fallback + deployment pipeline source 394 + clean-dark.json # Hardcoded fallback + deployment pipeline source 395 + classic-bb.json # Hardcoded fallback + deployment pipeline source 396 ``` 397 + 398 + The JSON files are **not** seeded into each forum's PDS on bootstrap. They serve as the hardcoded fallback (step 4 of the resolution waterfall) and as the source of truth for the release pipeline that publishes canonical records to `atbb.space`. See [Built-in Preset Themes](#built-in-preset-themes) for the full model. 399 400 ### Base Stylesheet Approach 401 ··· 493 AND has a preferredTheme set on their membership record? 494 AND does the forum's themePolicy.allowUserChoice == true? 495 AND is preferredTheme.uri still in themePolicy.availableThemes? 496 + AND (if preferredTheme.cid is set) does cid match current theme record? 497 → Use their preferred theme. 498 499 2. Color scheme default ··· 502 b. HTTP header: Sec-CH-Prefers-Color-Scheme (client hint) 503 c. Default: light 504 505 + → Use themePolicy.defaultDarkTheme or defaultLightTheme accordingly. 506 + If themeRef.cid is set, verify CID on fetch; mismatch → fall through. 507 + If themeRef.cid is absent, fetch live current record at the URI. 508 509 3. Hardcoded fallback 510 + If no theme policy exists, the resolved theme can't be loaded, 511 + or the atbb.space PDS is unreachable: 512 + → Use the built-in neobrutal token values from the bundled JSON files 513 + (no PDS or network needed, always works offline). 514 ``` 515 516 This is entirely **server-side** — no client JS framework needed. The web server resolves the theme before rendering and bakes the correct tokens into the HTML response.
+309
packages/cli/src/commands/theme.ts
···
··· 1 + import { defineCommand } from "citty"; 2 + import consola from "consola"; 3 + import { readFileSync } from "fs"; 4 + import { fileURLToPath } from "url"; 5 + import { dirname, join } from "path"; 6 + import { ForumAgent } from "@atbb/atproto"; 7 + import { loadCliConfig } from "../lib/config.js"; 8 + import { logger } from "../lib/logger.js"; 9 + 10 + const __dirname = dirname(fileURLToPath(import.meta.url)); 11 + const PRESET_DIR = join(__dirname, "../../../../apps/web/src/styles/presets"); 12 + 13 + const PRESETS = [ 14 + { rkey: "neobrutal-light", name: "Neobrutal Light", colorScheme: "light" as const }, 15 + { rkey: "neobrutal-dark", name: "Neobrutal Dark", colorScheme: "dark" as const }, 16 + { rkey: "clean-light", name: "Clean Light", colorScheme: "light" as const }, 17 + { rkey: "clean-dark", name: "Clean Dark", colorScheme: "dark" as const }, 18 + { rkey: "classic-bb", name: "Classic BB", colorScheme: "light" as const }, 19 + ] as const; 20 + 21 + /** Stable JSON string for comparison — sorted token keys, ignores ordering differences. */ 22 + function stableTokensJson(tokens: Record<string, string>): string { 23 + return JSON.stringify(Object.fromEntries(Object.entries(tokens).sort())); 24 + } 25 + 26 + /** True if the existing PDS record has the same content as the local preset. */ 27 + function isRecordCurrent( 28 + existing: Record<string, unknown>, 29 + preset: { name: string; colorScheme: string }, 30 + tokens: Record<string, string> 31 + ): boolean { 32 + return ( 33 + existing.name === preset.name && 34 + existing.colorScheme === preset.colorScheme && 35 + stableTokensJson(existing.tokens as Record<string, string>) === stableTokensJson(tokens) 36 + ); 37 + } 38 + 39 + /** 40 + * Authenticate using ForumAgent and return the raw agent + DID. 41 + * Theme commands only need PDS_URL, FORUM_HANDLE, FORUM_PASSWORD — 42 + * DATABASE_URL and FORUM_DID are not required. 43 + */ 44 + async function authenticate(config: ReturnType<typeof loadCliConfig>) { 45 + const themeEnvMissing = config.missing.filter( 46 + (v) => v !== "DATABASE_URL" && v !== "FORUM_DID" 47 + ); 48 + if (themeEnvMissing.length > 0) { 49 + consola.error("Missing required environment variables:"); 50 + for (const name of themeEnvMissing) consola.error(` - ${name}`); 51 + process.exit(1); 52 + } 53 + 54 + consola.start("Authenticating..."); 55 + const forumAgent = new ForumAgent( 56 + config.pdsUrl, config.forumHandle, config.forumPassword, logger 57 + ); 58 + 59 + try { 60 + await forumAgent.initialize(); 61 + } catch (error) { 62 + consola.error( 63 + `Failed to reach PDS (${config.pdsUrl}):`, 64 + error instanceof Error ? error.message : String(error) 65 + ); 66 + try { await forumAgent.shutdown(); } catch {} 67 + process.exit(1); 68 + } 69 + 70 + if (!forumAgent.isAuthenticated()) { 71 + const status = forumAgent.getStatus(); 72 + consola.error(`Authentication failed: ${status.error}`); 73 + await forumAgent.shutdown(); 74 + process.exit(1); 75 + } 76 + 77 + const agent = forumAgent.getAgent()!; 78 + const did = agent.session?.did; 79 + if (!did) { 80 + consola.error("Login succeeded but session has no DID"); 81 + await forumAgent.shutdown(); 82 + process.exit(1); 83 + } 84 + 85 + consola.success(`Authenticated as ${config.forumHandle} (${did})`); 86 + return { agent, did, forumAgent }; 87 + } 88 + 89 + // ── bootstrap-local ────────────────────────────────────────────────────────── 90 + 91 + const bootstrapLocalCommand = defineCommand({ 92 + meta: { 93 + name: "bootstrap-local", 94 + description: 95 + "Mirror built-in preset themes to your own PDS — zero external dependencies", 96 + }, 97 + args: { 98 + "dry-run": { 99 + type: "boolean", 100 + description: "Show what would be written without making any changes", 101 + default: false, 102 + }, 103 + }, 104 + async run({ args }) { 105 + const isDryRun = args["dry-run"]; 106 + consola.box("atBB — Bootstrap Local Presets" + (isDryRun ? " [dry-run]" : "")); 107 + 108 + const config = loadCliConfig(); 109 + const { agent, did, forumAgent } = await authenticate(config); 110 + 111 + consola.info(`Writing ${PRESETS.length} preset records to ${config.pdsUrl}`); 112 + if (isDryRun) consola.warn("Dry-run: no changes will be made."); 113 + consola.log(""); 114 + 115 + const now = new Date().toISOString(); 116 + const localUris: Array<{ rkey: string; uri: string }> = []; 117 + 118 + for (const preset of PRESETS) { 119 + const tokens = JSON.parse( 120 + readFileSync(join(PRESET_DIR, `${preset.rkey}.json`), "utf-8") 121 + ) as Record<string, string>; 122 + 123 + const uri = `at://${did}/space.atbb.forum.theme/${preset.rkey}`; 124 + localUris.push({ rkey: preset.rkey, uri }); 125 + 126 + if (isDryRun) { 127 + consola.info(` ~ ${preset.name} — would write ${uri}`); 128 + continue; 129 + } 130 + 131 + await agent.com.atproto.repo.putRecord({ 132 + repo: did, 133 + collection: "space.atbb.forum.theme", 134 + rkey: preset.rkey, 135 + record: { 136 + $type: "space.atbb.forum.theme", 137 + name: preset.name, 138 + colorScheme: preset.colorScheme, 139 + tokens, 140 + createdAt: now, 141 + updatedAt: now, 142 + }, 143 + }); 144 + consola.success(` ${preset.name}`); 145 + } 146 + 147 + const lightUri = localUris.find((t) => t.rkey === "neobrutal-light")!.uri; 148 + const darkUri = localUris.find((t) => t.rkey === "neobrutal-dark")!.uri; 149 + const available = localUris.map((t) => ({ uri: t.uri })); 150 + 151 + consola.log(""); 152 + 153 + // Show existing themePolicy before overwriting so operators can see what will change 154 + try { 155 + const existing = await agent.com.atproto.repo.getRecord({ 156 + repo: did, 157 + collection: "space.atbb.forum.themePolicy", 158 + rkey: "self", 159 + }); 160 + const rec = existing.data.value as Record<string, unknown>; 161 + const existingLight = (rec.defaultLightTheme as Record<string, string> | undefined)?.uri; 162 + const existingDark = (rec.defaultDarkTheme as Record<string, string> | undefined)?.uri; 163 + consola.info("Existing themePolicy:"); 164 + consola.info(` defaultLightTheme: ${existingLight ?? "(none)"}`); 165 + consola.info(` defaultDarkTheme: ${existingDark ?? "(none)"}`); 166 + } catch { 167 + consola.info("No existing themePolicy — will create."); 168 + } 169 + 170 + if (isDryRun) { 171 + consola.info(` ~ themePolicy — would write ${available.length} local refs`); 172 + consola.info(` defaultLightTheme: ${lightUri}`); 173 + consola.info(` defaultDarkTheme: ${darkUri}`); 174 + } else { 175 + await agent.com.atproto.repo.putRecord({ 176 + repo: did, 177 + collection: "space.atbb.forum.themePolicy", 178 + rkey: "self", 179 + record: { 180 + $type: "space.atbb.forum.themePolicy", 181 + availableThemes: available, 182 + defaultLightTheme: { uri: lightUri }, 183 + defaultDarkTheme: { uri: darkUri }, 184 + allowUserChoice: true, 185 + updatedAt: now, 186 + }, 187 + }); 188 + consola.success("themePolicy written"); 189 + consola.info(` defaultLightTheme: ${lightUri}`); 190 + consola.info(` defaultDarkTheme: ${darkUri}`); 191 + } 192 + 193 + await forumAgent.shutdown(); 194 + consola.log(""); 195 + consola.box( 196 + "Done — this forum now uses only local preset refs (no atbb.space dependency).\n" + 197 + "You can still customize presets in the admin theme editor." 198 + ); 199 + }, 200 + }); 201 + 202 + // ── publish-canonical ──────────────────────────────────────────────────────── 203 + 204 + const publishCanonicalCommand = defineCommand({ 205 + meta: { 206 + name: "publish-canonical", 207 + description: 208 + "[atbb.space only] Publish built-in preset themes to the canonical PDS. " + 209 + "Safe to re-run — uses upsert semantics, skips unchanged presets.", 210 + }, 211 + args: { 212 + "dry-run": { 213 + type: "boolean", 214 + description: "Show what would be written without making any changes", 215 + default: false, 216 + }, 217 + }, 218 + async run({ args }) { 219 + const isDryRun = args["dry-run"]; 220 + consola.box("atBB — Publish Canonical Presets" + (isDryRun ? " [dry-run]" : "")); 221 + 222 + const config = loadCliConfig(); 223 + const { agent, did, forumAgent } = await authenticate(config); 224 + 225 + consola.info(`Publishing ${PRESETS.length} presets to ${config.pdsUrl}`); 226 + if (isDryRun) consola.warn("Dry-run: no changes will be made."); 227 + consola.log(""); 228 + 229 + const now = new Date().toISOString(); 230 + let written = 0; 231 + let skipped = 0; 232 + 233 + for (const preset of PRESETS) { 234 + const tokens = JSON.parse( 235 + readFileSync(join(PRESET_DIR, `${preset.rkey}.json`), "utf-8") 236 + ) as Record<string, string>; 237 + 238 + // Fetch existing record to check for changes and preserve createdAt 239 + let existingCreatedAt: string | null = null; 240 + let alreadyCurrent = false; 241 + 242 + try { 243 + const res = await agent.com.atproto.repo.getRecord({ 244 + repo: did, 245 + collection: "space.atbb.forum.theme", 246 + rkey: preset.rkey, 247 + }); 248 + const existing = res.data.value as Record<string, unknown>; 249 + existingCreatedAt = (existing.createdAt as string) ?? null; 250 + if (isRecordCurrent(existing, preset, tokens)) alreadyCurrent = true; 251 + } catch (err: unknown) { 252 + // Only swallow 404 — record doesn't exist yet and will be created. 253 + // Re-throw anything else (network errors, auth failures, etc.). 254 + const status = (err as Record<string, unknown>).status; 255 + if (status !== 404) throw err; 256 + } 257 + 258 + if (alreadyCurrent) { 259 + consola.success(`${preset.name} — unchanged`); 260 + skipped++; 261 + continue; 262 + } 263 + 264 + const action = existingCreatedAt ? "updated" : "created"; 265 + 266 + if (isDryRun) { 267 + consola.info(` ~ ${preset.name} — would ${action}`); 268 + written++; 269 + continue; 270 + } 271 + 272 + const record: Record<string, unknown> = { 273 + $type: "space.atbb.forum.theme", 274 + name: preset.name, 275 + colorScheme: preset.colorScheme, 276 + tokens, 277 + createdAt: existingCreatedAt ?? now, 278 + }; 279 + // Only set updatedAt on updates, not initial creates 280 + if (existingCreatedAt) record.updatedAt = now; 281 + 282 + await agent.com.atproto.repo.putRecord({ 283 + repo: did, 284 + collection: "space.atbb.forum.theme", 285 + rkey: preset.rkey, 286 + record, 287 + }); 288 + consola.success(`${preset.name} — ${action}`); 289 + written++; 290 + } 291 + 292 + await forumAgent.shutdown(); 293 + consola.log(""); 294 + consola.info(`Done. ${written} written, ${skipped} unchanged.`); 295 + }, 296 + }); 297 + 298 + // ── theme command group ────────────────────────────────────────────────────── 299 + 300 + export const themeCommand = defineCommand({ 301 + meta: { 302 + name: "theme", 303 + description: "Manage forum themes and preset publishing", 304 + }, 305 + subCommands: { 306 + "bootstrap-local": bootstrapLocalCommand, 307 + "publish-canonical": publishCanonicalCommand, 308 + }, 309 + });
+2
packages/cli/src/index.ts
··· 3 import { initCommand } from "./commands/init.js"; 4 import { categoryCommand } from "./commands/category.js"; 5 import { boardCommand } from "./commands/board.js"; 6 7 const main = defineCommand({ 8 meta: { ··· 14 init: initCommand, 15 category: categoryCommand, 16 board: boardCommand, 17 }, 18 }); 19
··· 3 import { initCommand } from "./commands/init.js"; 4 import { categoryCommand } from "./commands/category.js"; 5 import { boardCommand } from "./commands/board.js"; 6 + import { themeCommand } from "./commands/theme.js"; 7 8 const main = defineCommand({ 9 meta: { ··· 15 init: initCommand, 16 category: categoryCommand, 17 board: boardCommand, 18 + theme: themeCommand, 19 }, 20 }); 21
+22
packages/css-sanitizer/src/__tests__/index.test.ts
··· 79 expect(warnings.filter((w) => w.includes("@import"))).toHaveLength(2); 80 }); 81 82 // ── external url() in declarations ─────────────────────────────────────── 83 84 it("strips declarations with http:// url()", () => { ··· 166 "div { width: expression(alert(1)); }" 167 ); 168 expect(css).not.toContain("expression("); 169 expect(warnings.some((w) => w.includes("width"))).toBe(true); 170 }); 171
··· 79 expect(warnings.filter((w) => w.includes("@import"))).toHaveLength(2); 80 }); 81 82 + it("strips @IMPORT with uppercase obfuscation", () => { 83 + // CSS keywords are case-insensitive per spec. The sanitizer normalizes via 84 + // node.name.toLowerCase() before comparison, so @IMPORT is caught even if 85 + // css-tree preserves the original casing in the AST node name. 86 + const { css, warnings } = sanitizeCssOverrides( 87 + '@IMPORT "https://evil.com/steal.css";' 88 + ); 89 + expect(css).not.toContain("evil.com"); 90 + expect(warnings.some((w) => w.includes("@import"))).toBe(true); 91 + }); 92 + 93 // ── external url() in declarations ─────────────────────────────────────── 94 95 it("strips declarations with http:// url()", () => { ··· 177 "div { width: expression(alert(1)); }" 178 ); 179 expect(css).not.toContain("expression("); 180 + expect(warnings.some((w) => w.includes("width"))).toBe(true); 181 + }); 182 + 183 + it("strips EXPRESSION() with uppercase obfuscation", () => { 184 + // The sanitizer normalizes via inner.name.toLowerCase() before comparison, 185 + // so EXPRESSION() is caught regardless of how css-tree stores the function name. 186 + const { css, warnings } = sanitizeCssOverrides( 187 + "div { width: EXPRESSION(alert(1)); }" 188 + ); 189 + expect(css).not.toContain("EXPRESSION("); 190 + expect(css).not.toContain("alert("); 191 expect(warnings.some((w) => w.includes("width"))).toBe(true); 192 }); 193
+1 -1
packages/db/src/schema.sqlite.ts
··· 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 );
··· 295 .notNull() 296 .references(() => themePolicies.id, { onDelete: "cascade" }), 297 themeUri: text("theme_uri").notNull(), 298 + themeCid: text("theme_cid"), 299 }, 300 (t) => [primaryKey({ columns: [t.policyId, t.themeUri] })] 301 );
+1 -1
packages/db/src/schema.ts
··· 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 );
··· 311 .notNull() 312 .references(() => themePolicies.id, { onDelete: "cascade" }), 313 themeUri: text("theme_uri").notNull(), 314 + themeCid: text("theme_cid"), 315 }, 316 (t) => [primaryKey({ columns: [t.policyId, t.themeUri] })] 317 );
+17 -7
packages/lexicon/lexicons/space/atbb/forum/themePolicy.yaml
··· 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.
··· 53 themeRef: 54 type: object 55 description: >- 56 + A reference to a theme record. When 'cid' is present the reference is 57 + pinned — the appview verifies the CID on fetch and falls through to the 58 + next resolution step on mismatch. When 'cid' is absent the reference is 59 + live — it always resolves the current record at that URI. The default 60 + themePolicy shipped with atBB uses live refs to canonical preset records 61 + on the atbb.space PDS. 62 required: 63 + - uri 64 properties: 65 + uri: 66 + type: string 67 + format: at-uri 68 + description: AT-URI of the space.atbb.forum.theme record. 69 + cid: 70 + type: string 71 + format: cid 72 description: >- 73 + Optional CID. When set, pins to this exact record version (strong 74 + reference). When absent, always resolves the current record at the 75 + URI (live reference).