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

Configure Feed

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

docs: add ATB-55 theme API implementation plan

Malpercio d9ae2945 55f092d6

+1214
+1214
docs/plans/2026-03-02-atb-55-theme-api.md
··· 1 + # ATB-55: Theme Read API Endpoints — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add `themes`, `theme_policies`, and `theme_policy_available_themes` tables, firehose indexers, and three read-only REST endpoints (`GET /api/themes`, `GET /api/themes/:rkey`, `GET /api/theme-policy`) to the AppView. 6 + 7 + **Architecture:** Dual-schema (Postgres + SQLite): `jsonb`/`text({mode:"json"})` for `tokens`, `text[].array()`/`text({mode:"json"})` for `fontUrls`. A normalized join table (`theme_policy_available_themes`) enables SQL-level filtering in `GET /api/themes`. Routes follow the `createXxxRoutes(ctx)` factory pattern; indexer follows the `CollectionConfig<TRecord>` pattern. 8 + 9 + **Tech Stack:** Drizzle ORM, Hono, Vitest, `@atbb/lexicon` (themePolicy types must be regenerated), `drizzle-kit generate` for migrations. 10 + 11 + **Design doc:** `docs/plans/2026-03-02-theme-api-design.md` 12 + 13 + --- 14 + 15 + ## ⚠️ Pre-flight: Environment 16 + 17 + All commands require the devenv PATH. Prefix every command with: 18 + ```bash 19 + export DEVENV="$(git rev-parse --show-toplevel)/.devenv/profile/bin" 20 + export PATH="$DEVENV:/bin:/usr/bin:$PATH" 21 + ``` 22 + 23 + The **lexicon `build:types` step** uses `shopt -s globstar` and **must** run inside `devenv shell`: 24 + ```bash 25 + devenv shell -- pnpm --filter @atbb/lexicon build 26 + ``` 27 + 28 + --- 29 + 30 + ## Task 1: Rebuild Lexicon to Generate `themePolicy` TypeScript Types 31 + 32 + **Why:** `themePolicy.json` was produced in ATB-51 but `themePolicy.ts` was never generated. `SpaceAtbbForumThemePolicy` does not yet exist in `@atbb/lexicon`. 33 + 34 + **Files:** 35 + - No edits needed — just run the build 36 + 37 + **Step 1: Run the lexicon build inside devenv shell** 38 + 39 + ```bash 40 + devenv shell -- pnpm --filter @atbb/lexicon build 41 + ``` 42 + 43 + Expected: build succeeds. The `build:types` step runs `lex gen-api` over all JSON lexicons including `themePolicy.json`. 44 + 45 + **Step 2: Verify themePolicy types were generated** 46 + 47 + ```bash 48 + ls packages/lexicon/dist/types/types/space/atbb/forum/ 49 + ``` 50 + 51 + Expected: `themePolicy.ts` (and `themePolicy.js`, `themePolicy.d.ts`) now present alongside `theme.ts`. 52 + 53 + **Step 3: Verify the export is in the index** 54 + 55 + ```bash 56 + grep "ThemePolicy" packages/lexicon/dist/types/index.ts 57 + ``` 58 + 59 + Expected: lines like `import * as SpaceAtbbForumThemePolicy from ...` and `export * as SpaceAtbbForumThemePolicy`. 60 + 61 + **Step 4: Commit** 62 + 63 + ```bash 64 + git add packages/lexicon/dist/ 65 + git commit -m "chore(lexicon): rebuild dist to generate themePolicy TypeScript types" 66 + ``` 67 + 68 + --- 69 + 70 + ## Task 2: Add Three New Tables to the Postgres Schema 71 + 72 + **Files:** 73 + - Modify: `packages/db/src/schema.ts` 74 + 75 + **Step 1: Add `jsonb` to the Postgres import** 76 + 77 + In `packages/db/src/schema.ts`, change the import from `drizzle-orm/pg-core` to include `jsonb`: 78 + 79 + ```typescript 80 + import { 81 + pgTable, 82 + bigserial, 83 + text, 84 + timestamp, 85 + integer, 86 + boolean, 87 + bigint, 88 + uniqueIndex, 89 + index, 90 + primaryKey, 91 + jsonb, // ADD THIS 92 + } from "drizzle-orm/pg-core"; 93 + ``` 94 + 95 + **Step 2: Append the three new table definitions at the end of `schema.ts`** 96 + 97 + ```typescript 98 + // ── themes ────────────────────────────────────────────── 99 + // Theme definitions, owned by Forum DID. Multiple themes per forum. 100 + // Key: tid (multiple records per repo). 101 + export const themes = pgTable( 102 + "themes", 103 + { 104 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 105 + did: text("did").notNull(), 106 + rkey: text("rkey").notNull(), 107 + cid: text("cid").notNull(), 108 + name: text("name").notNull(), 109 + colorScheme: text("color_scheme").notNull(), // "light" | "dark" 110 + tokens: jsonb("tokens").notNull(), // design token key-value map 111 + cssOverrides: text("css_overrides"), 112 + fontUrls: text("font_urls").array(), 113 + createdAt: timestamp("created_at", { withTimezone: true }), 114 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 115 + }, 116 + (table) => [uniqueIndex("themes_did_rkey_idx").on(table.did, table.rkey)] 117 + ); 118 + 119 + // ── theme_policies ─────────────────────────────────────── 120 + // Singleton theme policy, owned by Forum DID. 121 + // Key: literal:self (rkey is always "self"). 122 + export const themePolicies = pgTable( 123 + "theme_policies", 124 + { 125 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 126 + did: text("did").notNull(), 127 + rkey: text("rkey").notNull(), 128 + cid: text("cid").notNull(), 129 + defaultLightThemeUri: text("default_light_theme_uri").notNull(), 130 + defaultDarkThemeUri: text("default_dark_theme_uri").notNull(), 131 + allowUserChoice: boolean("allow_user_choice").notNull(), 132 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 133 + }, 134 + (table) => [ 135 + uniqueIndex("theme_policies_did_rkey_idx").on(table.did, table.rkey), 136 + ] 137 + ); 138 + 139 + // ── theme_policy_available_themes ──────────────────────── 140 + // Normalized join table: which themes does the policy expose to users? 141 + // ON DELETE CASCADE: deleting a policy row removes all its available-theme rows. 142 + export const themePolicyAvailableThemes = pgTable( 143 + "theme_policy_available_themes", 144 + { 145 + policyId: bigint("policy_id", { mode: "bigint" }) 146 + .notNull() 147 + .references(() => themePolicies.id, { onDelete: "cascade" }), 148 + themeUri: text("theme_uri").notNull(), 149 + themeCid: text("theme_cid").notNull(), 150 + }, 151 + (t) => [primaryKey({ columns: [t.policyId, t.themeUri] })] 152 + ); 153 + ``` 154 + 155 + --- 156 + 157 + ## Task 3: Add the Same Three Tables to the SQLite Schema 158 + 159 + **Files:** 160 + - Modify: `packages/db/src/schema.sqlite.ts` 161 + 162 + **Step 1: Append the three table definitions at the end of the file** 163 + 164 + Note: SQLite has no native `jsonb` or `text[]`. Use `text({ mode: "json" })` — Drizzle auto-serializes objects on insert and deserializes them on select. Use plain `text` for arrays too. 165 + 166 + ```typescript 167 + // ── themes ────────────────────────────────────────────── 168 + export const themes = sqliteTable( 169 + "themes", 170 + { 171 + id: integer("id").primaryKey({ autoIncrement: true }), 172 + did: text("did").notNull(), 173 + rkey: text("rkey").notNull(), 174 + cid: text("cid").notNull(), 175 + name: text("name").notNull(), 176 + colorScheme: text("color_scheme").notNull(), 177 + tokens: text("tokens", { mode: "json" }).notNull(), // auto JSON parse/stringify 178 + cssOverrides: text("css_overrides"), 179 + fontUrls: text("font_urls", { mode: "json" }), // auto JSON parse/stringify 180 + createdAt: integer("created_at", { mode: "timestamp" }), 181 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 182 + }, 183 + (table) => [uniqueIndex("themes_did_rkey_idx").on(table.did, table.rkey)] 184 + ); 185 + 186 + // ── theme_policies ─────────────────────────────────────── 187 + export const themePolicies = sqliteTable( 188 + "theme_policies", 189 + { 190 + id: integer("id").primaryKey({ autoIncrement: true }), 191 + did: text("did").notNull(), 192 + rkey: text("rkey").notNull(), 193 + cid: text("cid").notNull(), 194 + defaultLightThemeUri: text("default_light_theme_uri").notNull(), 195 + defaultDarkThemeUri: text("default_dark_theme_uri").notNull(), 196 + allowUserChoice: integer("allow_user_choice", { mode: "boolean" }).notNull(), 197 + indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 198 + }, 199 + (table) => [ 200 + uniqueIndex("theme_policies_did_rkey_idx").on(table.did, table.rkey), 201 + ] 202 + ); 203 + 204 + // ── theme_policy_available_themes ──────────────────────── 205 + export const themePolicyAvailableThemes = sqliteTable( 206 + "theme_policy_available_themes", 207 + { 208 + policyId: integer("policy_id") 209 + .notNull() 210 + .references(() => themePolicies.id, { onDelete: "cascade" }), 211 + themeUri: text("theme_uri").notNull(), 212 + themeCid: text("theme_cid").notNull(), 213 + }, 214 + (t) => [primaryKey({ columns: [t.policyId, t.themeUri] })] 215 + ); 216 + ``` 217 + 218 + --- 219 + 220 + ## Task 4: Build `@atbb/db` and Generate Both Migrations 221 + 222 + **Files:** 223 + - Create (generated): `apps/appview/drizzle/0013_*.sql` 224 + - Create (generated): `apps/appview/drizzle-sqlite/0001_*.sql` 225 + 226 + **Step 1: Build the DB package so the new tables are compiled** 227 + 228 + ```bash 229 + pnpm --filter @atbb/db build 230 + ``` 231 + 232 + Expected: `packages/db/dist/` rebuilt with new table exports. 233 + 234 + **Step 2: Generate the Postgres migration** 235 + 236 + ```bash 237 + pnpm --filter @atbb/appview exec drizzle-kit generate --config drizzle.postgres.config.ts 238 + ``` 239 + 240 + Expected: new file `apps/appview/drizzle/0013_*.sql` created with `CREATE TABLE themes`, `CREATE TABLE theme_policies`, `CREATE TABLE theme_policy_available_themes`. 241 + 242 + **Step 3: Generate the SQLite migration** 243 + 244 + ```bash 245 + pnpm --filter @atbb/appview exec drizzle-kit generate --config drizzle.sqlite.config.ts 246 + ``` 247 + 248 + Expected: new file `apps/appview/drizzle-sqlite/0001_*.sql` created with the SQLite equivalents. 249 + 250 + **Step 4: Inspect the generated SQL to verify correctness** 251 + 252 + ```bash 253 + cat apps/appview/drizzle/0013_*.sql 254 + cat apps/appview/drizzle-sqlite/0001_*.sql 255 + ``` 256 + 257 + Check: three `CREATE TABLE` statements, correct column types, `REFERENCES theme_policies(id) ON DELETE CASCADE` on the join table. 258 + 259 + **Step 5: Commit** 260 + 261 + ```bash 262 + git add packages/db/src/ packages/db/dist/ apps/appview/drizzle/ apps/appview/drizzle-sqlite/ 263 + git commit -m "feat(db): add themes, theme_policies, theme_policy_available_themes tables" 264 + ``` 265 + 266 + --- 267 + 268 + ## Task 5: Update Test Context Cleanup for New Tables 269 + 270 + **Files:** 271 + - Modify: `apps/appview/src/lib/__tests__/test-context.ts` 272 + 273 + **Step 1: Add new table imports** 274 + 275 + At the top where tables are imported from `@atbb/db`, add: 276 + 277 + ```typescript 278 + import { 279 + forums, posts, users, categories, memberships, boards, roles, modActions, 280 + backfillProgress, backfillErrors, 281 + themes, themePolicies, themePolicyAvailableThemes, // ADD THESE 282 + } from "@atbb/db"; 283 + ``` 284 + 285 + **Step 2: Add cleanup in `cleanDatabase()` — SQLite branch (delete ALL rows)** 286 + 287 + Inside the `if (isSqlite)` block, add before `await db.delete(forums)`: 288 + 289 + ```typescript 290 + // theme_policy_available_themes cascades from theme_policies, but explicit is clearer 291 + await db.delete(themePolicyAvailableThemes).catch(() => {}); 292 + await db.delete(themePolicies).catch(() => {}); 293 + await db.delete(themes).catch(() => {}); 294 + ``` 295 + 296 + **Step 3: Add cleanup in `cleanDatabase()` — Postgres branch (delete by DID)** 297 + 298 + After the `roles` delete, add: 299 + 300 + ```typescript 301 + // Deleting themePolicies cascades to theme_policy_available_themes 302 + await db.delete(themePolicies).where(eq(themePolicies.did, config.forumDid)).catch(() => {}); 303 + await db.delete(themes).where(eq(themes.did, config.forumDid)).catch(() => {}); 304 + ``` 305 + 306 + **Step 4: Add the same cleanup in `cleanup()`** 307 + 308 + In the `cleanup()` function body, add after the `roles` delete: 309 + 310 + ```typescript 311 + await db.delete(themePolicies).where(eq(themePolicies.did, config.forumDid)); 312 + await db.delete(themes).where(eq(themes.did, config.forumDid)); 313 + ``` 314 + 315 + (The cascade handles `theme_policy_available_themes` automatically in both branches.) 316 + 317 + **Step 5: Verify the test context compiles** 318 + 319 + ```bash 320 + pnpm --filter @atbb/appview exec tsc --noEmit 321 + ``` 322 + 323 + Expected: no errors. 324 + 325 + --- 326 + 327 + ## Task 6: Write Failing Tests for All Three Theme Endpoints 328 + 329 + **Files:** 330 + - Create: `apps/appview/src/routes/__tests__/themes.test.ts` 331 + 332 + Write the full test file — these tests will fail until Task 7 creates the route file. 333 + 334 + ```typescript 335 + import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 336 + import { Hono } from "hono"; 337 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 338 + import { createThemesRoutes, createThemePolicyRoutes } from "../themes.js"; 339 + import { themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 340 + 341 + // ── GET /api/themes ────────────────────────────────────── 342 + 343 + describe("GET /api/themes", () => { 344 + let ctx: TestContext; 345 + let app: Hono; 346 + 347 + beforeEach(async () => { 348 + ctx = await createTestContext(); 349 + app = new Hono() 350 + .route("/themes", createThemesRoutes(ctx)) 351 + .route("/theme-policy", createThemePolicyRoutes(ctx)); 352 + }); 353 + 354 + afterEach(async () => { 355 + await ctx.cleanup(); 356 + }); 357 + 358 + it("returns empty array when no policy exists", async () => { 359 + const res = await app.request("/themes"); 360 + expect(res.status).toBe(200); 361 + const body = await res.json(); 362 + expect(body).toHaveProperty("themes"); 363 + expect(body.themes).toEqual([]); 364 + }); 365 + 366 + it("returns only themes listed in availableThemes (not all themes in DB)", async () => { 367 + await ctx.db.insert(themes).values([ 368 + { 369 + did: ctx.config.forumDid, 370 + rkey: "3lbltheme1aa", 371 + cid: "bafytheme1", 372 + name: "Neobrutal Light", 373 + colorScheme: "light", 374 + tokens: { "color-bg": "#f5f0e8" }, 375 + indexedAt: new Date(), 376 + }, 377 + { 378 + did: ctx.config.forumDid, 379 + rkey: "3lbltheme2bb", 380 + cid: "bafytheme2", 381 + name: "Neobrutal Dark", 382 + colorScheme: "dark", 383 + tokens: { "color-bg": "#1a1a1a" }, 384 + indexedAt: new Date(), 385 + }, 386 + ]); 387 + 388 + const [policy] = await ctx.db 389 + .insert(themePolicies) 390 + .values({ 391 + did: ctx.config.forumDid, 392 + rkey: "self", 393 + cid: "bafypolicy1", 394 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme1aa`, 395 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme2bb`, 396 + allowUserChoice: true, 397 + indexedAt: new Date(), 398 + }) 399 + .returning(); 400 + 401 + // Only expose theme1 (light) — theme2 (dark) stays hidden 402 + await ctx.db.insert(themePolicyAvailableThemes).values({ 403 + policyId: policy.id, 404 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme1aa`, 405 + themeCid: "bafytheme1", 406 + }); 407 + 408 + const res = await app.request("/themes"); 409 + expect(res.status).toBe(200); 410 + const body = await res.json(); 411 + 412 + expect(body.themes).toHaveLength(1); 413 + expect(body.themes[0].name).toBe("Neobrutal Light"); 414 + // theme2 is in DB but NOT in availableThemes — must not appear 415 + const names = body.themes.map((t: any) => t.name); 416 + expect(names).not.toContain("Neobrutal Dark"); 417 + }); 418 + 419 + it("returns summary shape (id, uri, name, colorScheme, indexedAt — no tokens or cssOverrides)", async () => { 420 + await ctx.db.insert(themes).values({ 421 + did: ctx.config.forumDid, 422 + rkey: "3lbltheme3cc", 423 + cid: "bafytheme3", 424 + name: "Clean Light", 425 + colorScheme: "light", 426 + tokens: { "color-bg": "#ffffff" }, 427 + cssOverrides: ".card { border-radius: 4px; }", 428 + indexedAt: new Date(), 429 + }); 430 + 431 + const [policy] = await ctx.db 432 + .insert(themePolicies) 433 + .values({ 434 + did: ctx.config.forumDid, 435 + rkey: "self", 436 + cid: "bafypolicy2", 437 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 438 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 439 + allowUserChoice: true, 440 + indexedAt: new Date(), 441 + }) 442 + .returning(); 443 + 444 + await ctx.db.insert(themePolicyAvailableThemes).values({ 445 + policyId: policy.id, 446 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 447 + themeCid: "bafytheme3", 448 + }); 449 + 450 + const res = await app.request("/themes"); 451 + const body = await res.json(); 452 + const theme = body.themes[0]; 453 + 454 + expect(theme).toHaveProperty("id"); 455 + expect(theme).toHaveProperty("uri"); 456 + expect(theme.uri).toContain("/space.atbb.forum.theme/3lbltheme3cc"); 457 + expect(theme).toHaveProperty("name"); 458 + expect(theme).toHaveProperty("colorScheme"); 459 + expect(theme).toHaveProperty("indexedAt"); 460 + // List endpoint must NOT return full token set 461 + expect(theme).not.toHaveProperty("tokens"); 462 + expect(theme).not.toHaveProperty("cssOverrides"); 463 + expect(theme).not.toHaveProperty("fontUrls"); 464 + }); 465 + 466 + it("returns 503 on database error", async () => { 467 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 468 + throw new Error("Database connection lost"); 469 + }); 470 + 471 + const res = await app.request("/themes"); 472 + expect(res.status).toBe(503); 473 + }); 474 + }); 475 + 476 + // ── GET /api/themes/:rkey ──────────────────────────────── 477 + 478 + describe("GET /api/themes/:rkey", () => { 479 + let ctx: TestContext; 480 + let app: Hono; 481 + 482 + beforeEach(async () => { 483 + ctx = await createTestContext(); 484 + app = new Hono().route("/themes", createThemesRoutes(ctx)); 485 + }); 486 + 487 + afterEach(async () => { 488 + await ctx.cleanup(); 489 + }); 490 + 491 + it("returns 404 for unknown rkey", async () => { 492 + const res = await app.request("/themes/nonexistent"); 493 + expect(res.status).toBe(404); 494 + const body = await res.json(); 495 + expect(body.error).toBeDefined(); 496 + }); 497 + 498 + it("returns full theme data including tokens, cssOverrides, and fontUrls", async () => { 499 + await ctx.db.insert(themes).values({ 500 + did: ctx.config.forumDid, 501 + rkey: "3lblfulltest", 502 + cid: "bafyfull", 503 + name: "Neobrutal Light", 504 + colorScheme: "light", 505 + tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 506 + cssOverrides: ".btn { font-weight: 700; }", 507 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"], 508 + indexedAt: new Date(), 509 + }); 510 + 511 + const res = await app.request("/themes/3lblfulltest"); 512 + expect(res.status).toBe(200); 513 + const body = await res.json(); 514 + 515 + expect(body.name).toBe("Neobrutal Light"); 516 + expect(body.colorScheme).toBe("light"); 517 + expect(body.tokens).toEqual({ "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }); 518 + expect(body.cssOverrides).toBe(".btn { font-weight: 700; }"); 519 + expect(body.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 520 + expect(body.uri).toContain("/space.atbb.forum.theme/3lblfulltest"); 521 + expect(body.indexedAt).toBeDefined(); 522 + }); 523 + 524 + it("returns null for optional fields when not set", async () => { 525 + await ctx.db.insert(themes).values({ 526 + did: ctx.config.forumDid, 527 + rkey: "3lblminimal", 528 + cid: "bafymin", 529 + name: "Minimal", 530 + colorScheme: "light", 531 + tokens: { "color-bg": "#fff" }, 532 + indexedAt: new Date(), 533 + }); 534 + 535 + const res = await app.request("/themes/3lblminimal"); 536 + expect(res.status).toBe(200); 537 + const body = await res.json(); 538 + expect(body.cssOverrides).toBeNull(); 539 + expect(body.fontUrls).toBeNull(); 540 + }); 541 + 542 + it("returns 503 on database error", async () => { 543 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 544 + throw new Error("Database connection lost"); 545 + }); 546 + 547 + const res = await app.request("/themes/any-rkey"); 548 + expect(res.status).toBe(503); 549 + }); 550 + }); 551 + 552 + // ── GET /api/theme-policy ──────────────────────────────── 553 + 554 + describe("GET /api/theme-policy", () => { 555 + let ctx: TestContext; 556 + let app: Hono; 557 + 558 + beforeEach(async () => { 559 + ctx = await createTestContext(); 560 + app = new Hono().route("/theme-policy", createThemePolicyRoutes(ctx)); 561 + }); 562 + 563 + afterEach(async () => { 564 + await ctx.cleanup(); 565 + }); 566 + 567 + it("returns 404 when no policy exists", async () => { 568 + const res = await app.request("/theme-policy"); 569 + expect(res.status).toBe(404); 570 + const body = await res.json(); 571 + expect(body.error).toBeDefined(); 572 + }); 573 + 574 + it("returns policy with correct fields", async () => { 575 + const [policy] = await ctx.db 576 + .insert(themePolicies) 577 + .values({ 578 + did: ctx.config.forumDid, 579 + rkey: "self", 580 + cid: "bafypol", 581 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbllight`, 582 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbldark`, 583 + allowUserChoice: false, 584 + indexedAt: new Date(), 585 + }) 586 + .returning(); 587 + 588 + await ctx.db.insert(themePolicyAvailableThemes).values([ 589 + { 590 + policyId: policy.id, 591 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbllight`, 592 + themeCid: "bafylight", 593 + }, 594 + { 595 + policyId: policy.id, 596 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbldark`, 597 + themeCid: "bafydark", 598 + }, 599 + ]); 600 + 601 + const res = await app.request("/theme-policy"); 602 + expect(res.status).toBe(200); 603 + const body = await res.json(); 604 + 605 + expect(body.defaultLightThemeUri).toContain("3lbllight"); 606 + expect(body.defaultDarkThemeUri).toContain("3lbldark"); 607 + expect(body.allowUserChoice).toBe(false); 608 + expect(body.availableThemes).toHaveLength(2); 609 + expect(body.availableThemes[0]).toHaveProperty("uri"); 610 + expect(body.availableThemes[0]).toHaveProperty("cid"); 611 + }); 612 + 613 + it("returns 503 on database error", async () => { 614 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 615 + throw new Error("Database connection lost"); 616 + }); 617 + 618 + const res = await app.request("/theme-policy"); 619 + expect(res.status).toBe(503); 620 + }); 621 + }); 622 + ``` 623 + 624 + **Step 2: Run to verify the tests fail (expected — route file doesn't exist yet)** 625 + 626 + ```bash 627 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/themes.test.ts 628 + ``` 629 + 630 + Expected: FAIL with import error (module `../themes.js` not found) or TypeScript compile error. 631 + 632 + --- 633 + 634 + ## Task 7: Implement `routes/themes.ts` 635 + 636 + **Files:** 637 + - Create: `apps/appview/src/routes/themes.ts` 638 + 639 + ```typescript 640 + import { Hono } from "hono"; 641 + import type { AppContext } from "../lib/app-context.js"; 642 + import { themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 643 + import { eq, inArray, and } from "drizzle-orm"; 644 + import { serializeBigInt, serializeDate } from "./helpers.js"; 645 + import { handleRouteError } from "../lib/route-errors.js"; 646 + import { parseAtUri } from "../lib/at-uri.js"; 647 + 648 + type ThemeRow = typeof themes.$inferSelect; 649 + 650 + function serializeThemeSummary(theme: ThemeRow) { 651 + return { 652 + id: serializeBigInt(theme.id), 653 + uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 654 + name: theme.name, 655 + colorScheme: theme.colorScheme, 656 + indexedAt: serializeDate(theme.indexedAt), 657 + }; 658 + } 659 + 660 + function serializeThemeFull(theme: ThemeRow) { 661 + return { 662 + id: serializeBigInt(theme.id), 663 + uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 664 + name: theme.name, 665 + colorScheme: theme.colorScheme, 666 + tokens: theme.tokens, 667 + cssOverrides: theme.cssOverrides ?? null, 668 + fontUrls: (theme.fontUrls as string[] | null) ?? null, 669 + createdAt: serializeDate(theme.createdAt), 670 + indexedAt: serializeDate(theme.indexedAt), 671 + }; 672 + } 673 + 674 + export function createThemesRoutes(ctx: AppContext) { 675 + return new Hono() 676 + .get("/", async (c) => { 677 + try { 678 + // Step 1: Get available theme URIs from this forum's policy 679 + const availableRows = await ctx.db 680 + .select({ themeUri: themePolicyAvailableThemes.themeUri }) 681 + .from(themePolicyAvailableThemes) 682 + .innerJoin( 683 + themePolicies, 684 + eq(themePolicies.id, themePolicyAvailableThemes.policyId) 685 + ) 686 + .where(eq(themePolicies.did, ctx.config.forumDid)); 687 + 688 + if (availableRows.length === 0) { 689 + return c.json({ themes: [] }); 690 + } 691 + 692 + // Step 2: Parse rkeys from AT-URIs 693 + const rkeys = availableRows 694 + .map((r) => parseAtUri(r.themeUri)?.rkey) 695 + .filter((rkey): rkey is string => !!rkey); 696 + 697 + if (rkeys.length === 0) { 698 + return c.json({ themes: [] }); 699 + } 700 + 701 + // Step 3: Fetch matching themes 702 + const themeList = await ctx.db 703 + .select() 704 + .from(themes) 705 + .where( 706 + and( 707 + eq(themes.did, ctx.config.forumDid), 708 + inArray(themes.rkey, rkeys) 709 + ) 710 + ) 711 + .limit(100); 712 + 713 + return c.json({ themes: themeList.map(serializeThemeSummary) }); 714 + } catch (error) { 715 + return handleRouteError(c, error, "Failed to retrieve themes", { 716 + operation: "GET /api/themes", 717 + logger: ctx.logger, 718 + }); 719 + } 720 + }) 721 + .get("/:rkey", async (c) => { 722 + const rkey = c.req.param("rkey").trim(); 723 + if (!rkey) { 724 + return c.json({ error: "Invalid theme rkey" }, 400); 725 + } 726 + 727 + try { 728 + const [theme] = await ctx.db 729 + .select() 730 + .from(themes) 731 + .where( 732 + and( 733 + eq(themes.did, ctx.config.forumDid), 734 + eq(themes.rkey, rkey) 735 + ) 736 + ) 737 + .limit(1); 738 + 739 + if (!theme) { 740 + return c.json({ error: "Theme not found" }, 404); 741 + } 742 + 743 + return c.json(serializeThemeFull(theme)); 744 + } catch (error) { 745 + return handleRouteError(c, error, "Failed to retrieve theme", { 746 + operation: "GET /api/themes/:rkey", 747 + logger: ctx.logger, 748 + themeRkey: rkey, 749 + }); 750 + } 751 + }); 752 + } 753 + 754 + export function createThemePolicyRoutes(ctx: AppContext) { 755 + return new Hono().get("/", async (c) => { 756 + try { 757 + const [policy] = await ctx.db 758 + .select() 759 + .from(themePolicies) 760 + .where(eq(themePolicies.did, ctx.config.forumDid)) 761 + .limit(1); 762 + 763 + if (!policy) { 764 + return c.json({ error: "Theme policy not found" }, 404); 765 + } 766 + 767 + const available = await ctx.db 768 + .select({ 769 + themeUri: themePolicyAvailableThemes.themeUri, 770 + themeCid: themePolicyAvailableThemes.themeCid, 771 + }) 772 + .from(themePolicyAvailableThemes) 773 + .where(eq(themePolicyAvailableThemes.policyId, policy.id)); 774 + 775 + return c.json({ 776 + defaultLightThemeUri: policy.defaultLightThemeUri, 777 + defaultDarkThemeUri: policy.defaultDarkThemeUri, 778 + allowUserChoice: policy.allowUserChoice, 779 + availableThemes: available.map((t) => ({ 780 + uri: t.themeUri, 781 + cid: t.themeCid, 782 + })), 783 + }); 784 + } catch (error) { 785 + return handleRouteError(c, error, "Failed to retrieve theme policy", { 786 + operation: "GET /api/theme-policy", 787 + logger: ctx.logger, 788 + }); 789 + } 790 + }); 791 + } 792 + ``` 793 + 794 + --- 795 + 796 + ## Task 8: Register Routes and Run Tests 797 + 798 + **Files:** 799 + - Modify: `apps/appview/src/routes/index.ts` 800 + 801 + **Step 1: Import and register the new routes** 802 + 803 + Add to the imports at the top: 804 + ```typescript 805 + import { createThemesRoutes, createThemePolicyRoutes } from "./themes.js"; 806 + ``` 807 + 808 + Add to the `createApiRoutes` function: 809 + ```typescript 810 + .route("/themes", createThemesRoutes(ctx)) 811 + .route("/theme-policy", createThemePolicyRoutes(ctx)) 812 + ``` 813 + 814 + Also add stub routes for the test-only `apiRoutes` export at the bottom: 815 + ```typescript 816 + const stubThemesRoutes = new Hono().get("/", (c) => c.json({ themes: [] })); 817 + const stubThemePolicyRoutes = new Hono().get("/", (c) => c.json({ error: "not found" }, 404)); 818 + ``` 819 + 820 + And register them on `apiRoutes`: 821 + ```typescript 822 + .route("/themes", stubThemesRoutes) 823 + .route("/theme-policy", stubThemePolicyRoutes) 824 + ``` 825 + 826 + **Step 2: Run the full theme test suite** 827 + 828 + ```bash 829 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/themes.test.ts 830 + ``` 831 + 832 + Expected: ALL tests PASS. 833 + 834 + **Step 3: Run the full test suite to catch regressions** 835 + 836 + ```bash 837 + pnpm --filter @atbb/appview exec vitest run 838 + ``` 839 + 840 + Expected: all tests pass. 841 + 842 + **Step 4: Commit** 843 + 844 + ```bash 845 + git add apps/appview/src/routes/themes.ts apps/appview/src/routes/index.ts \ 846 + apps/appview/src/routes/__tests__/themes.test.ts \ 847 + apps/appview/src/lib/__tests__/test-context.ts 848 + git commit -m "feat(appview): add GET /api/themes, /api/themes/:rkey, /api/theme-policy endpoints (ATB-55)" 849 + ``` 850 + 851 + --- 852 + 853 + ## Task 9: Add Indexer Configs for Theme and ThemePolicy 854 + 855 + **Files:** 856 + - Modify: `apps/appview/src/lib/indexer.ts` 857 + 858 + **Step 1: Add imports for new tables and lexicon types** 859 + 860 + At the top, add to the DB table imports: 861 + ```typescript 862 + import { 863 + posts, forums, categories, boards, users, memberships, modActions, roles, rolePermissions, 864 + themes, themePolicies, themePolicyAvailableThemes, // ADD THESE 865 + } from "@atbb/db"; 866 + ``` 867 + 868 + Add to the lexicon imports: 869 + ```typescript 870 + import { 871 + SpaceAtbbPost as Post, 872 + SpaceAtbbForumForum as Forum, 873 + SpaceAtbbForumCategory as Category, 874 + SpaceAtbbForumBoard as Board, 875 + SpaceAtbbMembership as Membership, 876 + SpaceAtbbModAction as ModAction, 877 + SpaceAtbbForumRole as Role, 878 + SpaceAtbbForumTheme as Theme, // ADD 879 + SpaceAtbbForumThemePolicy as ThemePolicy, // ADD 880 + } from "@atbb/lexicon"; 881 + ``` 882 + 883 + **Step 2: Add `themeConfig` after the existing `boardConfig`** 884 + 885 + ```typescript 886 + private themeConfig: CollectionConfig<Theme.Record> = { 887 + name: "Theme", 888 + table: themes, 889 + deleteStrategy: "hard", 890 + toInsertValues: async (event, record) => ({ 891 + did: event.did, 892 + rkey: event.commit.rkey, 893 + cid: event.commit.cid, 894 + name: record.name, 895 + colorScheme: record.colorScheme as string, 896 + tokens: record.tokens, 897 + cssOverrides: (record.cssOverrides as string | undefined) ?? null, 898 + fontUrls: (record.fontUrls as string[] | undefined) ?? null, 899 + createdAt: record.createdAt ? new Date(record.createdAt as string) : null, 900 + indexedAt: new Date(), 901 + }), 902 + toUpdateValues: async (event, record) => ({ 903 + cid: event.commit.cid, 904 + name: record.name, 905 + colorScheme: record.colorScheme as string, 906 + tokens: record.tokens, 907 + cssOverrides: (record.cssOverrides as string | undefined) ?? null, 908 + fontUrls: (record.fontUrls as string[] | undefined) ?? null, 909 + indexedAt: new Date(), 910 + }), 911 + }; 912 + ``` 913 + 914 + **Step 3: Add `themePolicyConfig` after `themeConfig`** 915 + 916 + Note: `SpaceAtbbForumThemePolicy.Record` will be generated by Task 1. The `availableThemes`, `defaultLightTheme`, and `defaultDarkTheme` fields use `as any` because the lexicon generator emits `[k: string]: unknown` for complex refs. Adjust the casts if the generated interface exposes them explicitly. 917 + 918 + ```typescript 919 + private themePolicyConfig: CollectionConfig<ThemePolicy.Record> = { 920 + name: "ThemePolicy", 921 + table: themePolicies, 922 + deleteStrategy: "hard", 923 + toInsertValues: async (event, record) => ({ 924 + did: event.did, 925 + rkey: event.commit.rkey, 926 + cid: event.commit.cid, 927 + defaultLightThemeUri: (record.defaultLightTheme as any).theme.uri as string, 928 + defaultDarkThemeUri: (record.defaultDarkTheme as any).theme.uri as string, 929 + allowUserChoice: record.allowUserChoice as boolean, 930 + indexedAt: new Date(), 931 + }), 932 + toUpdateValues: async (event, record) => ({ 933 + cid: event.commit.cid, 934 + defaultLightThemeUri: (record.defaultLightTheme as any).theme.uri as string, 935 + defaultDarkThemeUri: (record.defaultDarkTheme as any).theme.uri as string, 936 + allowUserChoice: record.allowUserChoice as boolean, 937 + indexedAt: new Date(), 938 + }), 939 + afterUpsert: async (_event, record, policyId, tx) => { 940 + // Atomically replace all available-theme rows for this policy 941 + await tx 942 + .delete(themePolicyAvailableThemes) 943 + .where(eq(themePolicyAvailableThemes.policyId, policyId)); 944 + 945 + const available = (record.availableThemes as any[]) ?? []; 946 + if (available.length > 0) { 947 + await tx.insert(themePolicyAvailableThemes).values( 948 + available.map((themeRef: any) => ({ 949 + policyId, 950 + themeUri: themeRef.theme.uri as string, 951 + themeCid: themeRef.theme.cid as string, 952 + })) 953 + ); 954 + } 955 + }, 956 + }; 957 + ``` 958 + 959 + **Step 4: Wire the configs to handler methods** 960 + 961 + Find the section where `handlePostCreate`, `handleForumCreate`, etc. are defined (these are generated from `CollectionConfig` via a generic handler). Look for the pattern of how the indexer exposes public handler methods and wire `themeConfig` and `themePolicyConfig` the same way the existing configs are wired. 962 + 963 + Check the bottom of `indexer.ts` for the generic handler wiring. It likely looks like: 964 + ```typescript 965 + handleThemeCreate = this.createHandler(this.themeConfig, "create"); 966 + handleThemeUpdate = this.createHandler(this.themeConfig, "update"); 967 + handleThemeDelete = this.createHandler(this.themeConfig, "delete"); 968 + handleThemePolicyCreate = this.createHandler(this.themePolicyConfig, "create"); 969 + handleThemePolicyUpdate = this.createHandler(this.themePolicyConfig, "update"); 970 + handleThemePolicyDelete = this.createHandler(this.themePolicyConfig, "delete"); 971 + ``` 972 + 973 + > **Note:** Read the bottom 100 lines of `indexer.ts` first to understand the exact wiring pattern, then follow it exactly for theme and themePolicy. 974 + 975 + --- 976 + 977 + ## Task 10: Register Theme and ThemePolicy in the Firehose 978 + 979 + **Files:** 980 + - Modify: `apps/appview/src/lib/firehose.ts` 981 + 982 + **Step 1: Add two `.register()` blocks in `createHandlerRegistry()`** 983 + 984 + After the existing `.register({ collection: "space.atbb.reaction", ... })` block: 985 + 986 + ```typescript 987 + .register({ 988 + collection: "space.atbb.forum.theme", 989 + onCreate: this.createWrappedHandler("handleThemeCreate"), 990 + onUpdate: this.createWrappedHandler("handleThemeUpdate"), 991 + onDelete: this.createWrappedHandler("handleThemeDelete"), 992 + }) 993 + .register({ 994 + collection: "space.atbb.forum.themePolicy", 995 + onCreate: this.createWrappedHandler("handleThemePolicyCreate"), 996 + onUpdate: this.createWrappedHandler("handleThemePolicyUpdate"), 997 + onDelete: this.createWrappedHandler("handleThemePolicyDelete"), 998 + }) 999 + ``` 1000 + 1001 + **Step 2: Verify TypeScript compiles** 1002 + 1003 + ```bash 1004 + pnpm --filter @atbb/appview exec tsc --noEmit 1005 + ``` 1006 + 1007 + Expected: no errors. 1008 + 1009 + **Step 3: Run all tests** 1010 + 1011 + ```bash 1012 + pnpm --filter @atbb/appview exec vitest run 1013 + ``` 1014 + 1015 + Expected: all tests pass (indexer changes don't affect route tests). 1016 + 1017 + **Step 4: Commit** 1018 + 1019 + ```bash 1020 + git add apps/appview/src/lib/indexer.ts apps/appview/src/lib/firehose.ts 1021 + git commit -m "feat(appview): index space.atbb.forum.theme and themePolicy from firehose (ATB-55)" 1022 + ``` 1023 + 1024 + --- 1025 + 1026 + ## Task 11: Write Bruno Collection Files 1027 + 1028 + **Files:** 1029 + - Create: `bruno/AppView API/Themes/List Available Themes.bru` 1030 + - Create: `bruno/AppView API/Themes/Get Theme.bru` 1031 + - Create: `bruno/AppView API/Themes/Get Theme Policy.bru` 1032 + 1033 + **`List Available Themes.bru`:** 1034 + ``` 1035 + meta { 1036 + name: List Available Themes 1037 + type: http 1038 + seq: 1 1039 + } 1040 + 1041 + get { 1042 + url: {{appview_url}}/api/themes 1043 + } 1044 + 1045 + assert { 1046 + res.status: eq 200 1047 + res.body.themes: isDefined 1048 + } 1049 + 1050 + docs { 1051 + Returns themes filtered to those in the forum's themePolicy.availableThemes. 1052 + Returns an empty array if no theme policy has been published. 1053 + 1054 + Returns: 1055 + { 1056 + "themes": [ 1057 + { 1058 + "id": "1", 1059 + "uri": "at://did:plc:.../space.atbb.forum.theme/...", 1060 + "name": "Neobrutal Light", 1061 + "colorScheme": "light", 1062 + "indexedAt": "2026-03-01T00:00:00.000Z" 1063 + } 1064 + ] 1065 + } 1066 + 1067 + Error codes: 1068 + - 500: Server error 1069 + - 503: Database temporarily unavailable 1070 + } 1071 + ``` 1072 + 1073 + **`Get Theme.bru`:** 1074 + ``` 1075 + meta { 1076 + name: Get Theme 1077 + type: http 1078 + seq: 2 1079 + } 1080 + 1081 + get { 1082 + url: {{appview_url}}/api/themes/{{theme_rkey}} 1083 + } 1084 + 1085 + assert { 1086 + res.status: eq 200 1087 + res.body.name: isDefined 1088 + res.body.tokens: isDefined 1089 + } 1090 + 1091 + docs { 1092 + Returns full theme data (name, colorScheme, tokens, cssOverrides, fontUrls) 1093 + for the theme identified by its rkey (TID). 1094 + 1095 + Set the theme_rkey environment variable to a valid theme rkey before running. 1096 + 1097 + Path params: 1098 + - rkey: Theme record key (TID, e.g. 3lblexample) 1099 + 1100 + Returns: 1101 + { 1102 + "id": "1", 1103 + "uri": "at://did:plc:.../space.atbb.forum.theme/3lblexample", 1104 + "name": "Neobrutal Light", 1105 + "colorScheme": "light", 1106 + "tokens": { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 1107 + "cssOverrides": null, 1108 + "fontUrls": null, 1109 + "createdAt": "2026-03-01T00:00:00.000Z", 1110 + "indexedAt": "2026-03-01T00:00:00.000Z" 1111 + } 1112 + 1113 + Error codes: 1114 + - 400: Invalid rkey (empty) 1115 + - 404: Theme not found 1116 + - 500: Server error 1117 + - 503: Database temporarily unavailable 1118 + } 1119 + ``` 1120 + 1121 + **`Get Theme Policy.bru`:** 1122 + ``` 1123 + meta { 1124 + name: Get Theme Policy 1125 + type: http 1126 + seq: 3 1127 + } 1128 + 1129 + get { 1130 + url: {{appview_url}}/api/theme-policy 1131 + } 1132 + 1133 + assert { 1134 + res.status: eq 200 1135 + res.body.allowUserChoice: isDefined 1136 + res.body.availableThemes: isDefined 1137 + } 1138 + 1139 + docs { 1140 + Returns the forum's theme policy: which themes are available to users, 1141 + the default light and dark themes, and whether users can pick their own. 1142 + 1143 + Returns: 1144 + { 1145 + "defaultLightThemeUri": "at://did:plc:.../space.atbb.forum.theme/...", 1146 + "defaultDarkThemeUri": "at://did:plc:.../space.atbb.forum.theme/...", 1147 + "allowUserChoice": true, 1148 + "availableThemes": [ 1149 + { "uri": "at://did:plc:.../space.atbb.forum.theme/...", "cid": "bafy..." } 1150 + ] 1151 + } 1152 + 1153 + Error codes: 1154 + - 404: No theme policy published yet 1155 + - 500: Server error 1156 + - 503: Database temporarily unavailable 1157 + } 1158 + ``` 1159 + 1160 + **Step 2: Commit** 1161 + 1162 + ```bash 1163 + git add bruno/AppView API/Themes/ 1164 + git commit -m "docs(bruno): add Themes API collection (ATB-55)" 1165 + ``` 1166 + 1167 + --- 1168 + 1169 + ## Task 12: Final Verification + Linear Update 1170 + 1171 + **Step 1: Run the full test suite** 1172 + 1173 + ```bash 1174 + pnpm --filter @atbb/appview exec vitest run 1175 + ``` 1176 + 1177 + Expected: all tests pass, no skipped tests. 1178 + 1179 + **Step 2: Typecheck the whole monorepo** 1180 + 1181 + ```bash 1182 + pnpm --filter @atbb/appview exec tsc --noEmit 1183 + pnpm --filter @atbb/db exec tsc --noEmit 1184 + ``` 1185 + 1186 + Expected: no errors. 1187 + 1188 + **Step 3: Update Linear** 1189 + 1190 + - Change ATB-55 status to **In Review** 1191 + - Add a comment: "Implementation complete. PRs: [link]. Endpoints: GET /api/themes, GET /api/themes/:rkey, GET /api/theme-policy. Indexer: space.atbb.forum.theme + space.atbb.forum.themePolicy." 1192 + 1193 + **Step 4: Push and open PR** 1194 + 1195 + ```bash 1196 + git push origin HEAD 1197 + gh pr create \ 1198 + --title "feat(appview): theme read API endpoints (ATB-55)" \ 1199 + --body "$(cat <<'EOF' 1200 + ## Summary 1201 + - Adds `themes`, `theme_policies`, `theme_policy_available_themes` tables to both Postgres and SQLite schemas 1202 + - Indexes `space.atbb.forum.theme` and `space.atbb.forum.themePolicy` from the Jetstream firehose 1203 + - Implements `GET /api/themes`, `GET /api/themes/:rkey`, `GET /api/theme-policy` read endpoints 1204 + - Bruno collection added for all three endpoints 1205 + 1206 + ## Test plan 1207 + - [ ] All theme route tests pass (`vitest run src/routes/__tests__/themes.test.ts`) 1208 + - [ ] Full suite passes (`pnpm --filter @atbb/appview exec vitest run`) 1209 + - [ ] TypeScript compiles clean (`tsc --noEmit`) 1210 + - [ ] Postgres migration reviewed (three new CREATE TABLE statements) 1211 + - [ ] SQLite migration reviewed 1212 + EOF 1213 + )" 1214 + ```