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 implementation plan for ATB-57 theme write API endpoints

Malpercio 8ef04dae 1cc1e3a2

+1417
+1417
docs/plans/2026-03-02-atb-57-theme-write-api.md
··· 1 + # ATB-57: Theme Write API Endpoints — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add admin write endpoints for creating, updating, and deleting themes, and managing the theme policy singleton. 6 + 7 + **Architecture:** All four endpoints live in `apps/appview/src/routes/admin.ts` (same file as category/board write endpoints), gated by a new `space.atbb.permission.manageThemes` permission. They follow the established PDS-first pattern: validate → get ForumAgent → `putRecord`/`deleteRecord` on Forum DID's PDS → return `{ uri, cid }`. The firehose indexer handles DB rows asynchronously. 8 + 9 + **Tech Stack:** Hono, Drizzle ORM (postgres.js), AT Protocol (`com.atproto.repo.putRecord`), `@atproto/common-web` TID generator, Vitest 10 + 11 + --- 12 + 13 + ## Context & Patterns to Know 14 + 15 + **The PDS-first write pattern** (established by categories/boards in `admin.ts`): 16 + 1. Parse + validate request body (`safeParseJsonBody`) 17 + 2. For PUT/DELETE: look up existing DB row first — 404 if missing 18 + 3. `getForumAgentOrError` — returns 503 if ForumAgent not configured 19 + 4. Call `agent.com.atproto.repo.putRecord` / `deleteRecord` 20 + 5. Return `{ uri, cid }` from result 21 + 22 + **Test scaffolding** (from `admin.test.ts`): Auth and permissions middleware are **mocked at module level** — `requireAuth` always passes the `mockUser` object, `requirePermission` always calls `next()`. Tests set `ctx.forumAgent` to a mock with `mockPutRecord` / `mockDeleteRecord`. Tests use `describe.sequential` because they share a single `TestContext`. 23 + 24 + **Running tests:** 25 + ```bash 26 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview test 27 + # or for a single file: 28 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 29 + ``` 30 + 31 + Replace `/path/to/` with the absolute path to the repo root (check with `pwd`). 32 + 33 + **Imports needed in `admin.ts`** — you'll need to add: 34 + - `themes, themePolicies` to the `@atbb/db` import 35 + - `or` to the `drizzle-orm` import (it's not there yet) 36 + 37 + --- 38 + 39 + ## Task 1: Add `manageThemes` permission to seed-roles 40 + 41 + **Files:** 42 + - Modify: `apps/appview/src/lib/seed-roles.ts` 43 + 44 + No test needed (it's runtime seed data, not business logic). The Admin role needs `space.atbb.permission.manageThemes` added. 45 + 46 + **Step 1: Add permission to Admin role** 47 + 48 + In `seed-roles.ts`, find the `"Admin"` entry in `DEFAULT_ROLES` and add `"space.atbb.permission.manageThemes"` to its `permissions` array: 49 + 50 + ```typescript 51 + { 52 + name: "Admin", 53 + description: "Can manage forum structure and users", 54 + permissions: [ 55 + "space.atbb.permission.manageCategories", 56 + "space.atbb.permission.manageRoles", 57 + "space.atbb.permission.manageMembers", 58 + "space.atbb.permission.manageThemes", // ← add this line 59 + "space.atbb.permission.moderatePosts", 60 + "space.atbb.permission.banUsers", 61 + "space.atbb.permission.pinTopics", 62 + "space.atbb.permission.lockTopics", 63 + "space.atbb.permission.createTopics", 64 + "space.atbb.permission.createPosts", 65 + ], 66 + priority: 10, 67 + critical: true, 68 + }, 69 + ``` 70 + 71 + **Step 2: Verify existing tests still pass** 72 + 73 + ```bash 74 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run 75 + ``` 76 + 77 + Expected: all existing tests pass. 78 + 79 + **Step 3: Commit** 80 + 81 + ```bash 82 + git add apps/appview/src/lib/seed-roles.ts 83 + git commit -m "feat(appview): add manageThemes permission to Admin role (ATB-57)" 84 + ``` 85 + 86 + --- 87 + 88 + ## Task 2: Write failing tests for `POST /api/admin/themes` 89 + 90 + **Files:** 91 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 92 + 93 + **Step 1: Add import for `themes` table** 94 + 95 + At the top of `admin.test.ts`, find the `@atbb/db` import and add `themes`: 96 + 97 + ```typescript 98 + import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions, themes } from "@atbb/db"; 99 + ``` 100 + 101 + **Step 2: Add the test describe block** 102 + 103 + At the bottom of the file (inside `describe.sequential("Admin Routes", ...)`, before the closing brace), add: 104 + 105 + ```typescript 106 + describe("POST /api/admin/themes", () => { 107 + it("creates theme and returns 201 with uri and cid", async () => { 108 + const res = await app.request("/api/admin/themes", { 109 + method: "POST", 110 + headers: { "Content-Type": "application/json" }, 111 + body: JSON.stringify({ 112 + name: "Neobrutal Light", 113 + colorScheme: "light", 114 + tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 115 + }), 116 + }); 117 + expect(res.status).toBe(201); 118 + const body = await res.json(); 119 + expect(body.uri).toBeDefined(); 120 + expect(body.cid).toBeDefined(); 121 + expect(mockPutRecord).toHaveBeenCalledOnce(); 122 + }); 123 + 124 + it("includes cssOverrides and fontUrls when provided", async () => { 125 + const res = await app.request("/api/admin/themes", { 126 + method: "POST", 127 + headers: { "Content-Type": "application/json" }, 128 + body: JSON.stringify({ 129 + name: "Custom Theme", 130 + colorScheme: "dark", 131 + tokens: { "color-bg": "#1a1a1a" }, 132 + cssOverrides: ".card { border-radius: 4px; }", 133 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"], 134 + }), 135 + }); 136 + expect(res.status).toBe(201); 137 + const call = mockPutRecord.mock.calls[0][0]; 138 + expect(call.record.cssOverrides).toBe(".card { border-radius: 4px; }"); 139 + expect(call.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 140 + }); 141 + 142 + it("returns 400 when name is missing", async () => { 143 + const res = await app.request("/api/admin/themes", { 144 + method: "POST", 145 + headers: { "Content-Type": "application/json" }, 146 + body: JSON.stringify({ colorScheme: "light", tokens: {} }), 147 + }); 148 + expect(res.status).toBe(400); 149 + const body = await res.json(); 150 + expect(body.error).toMatch(/name/i); 151 + }); 152 + 153 + it("returns 400 when name is empty string", async () => { 154 + const res = await app.request("/api/admin/themes", { 155 + method: "POST", 156 + headers: { "Content-Type": "application/json" }, 157 + body: JSON.stringify({ name: " ", colorScheme: "light", tokens: {} }), 158 + }); 159 + expect(res.status).toBe(400); 160 + }); 161 + 162 + it("returns 400 when colorScheme is invalid", async () => { 163 + const res = await app.request("/api/admin/themes", { 164 + method: "POST", 165 + headers: { "Content-Type": "application/json" }, 166 + body: JSON.stringify({ name: "Test", colorScheme: "purple", tokens: {} }), 167 + }); 168 + expect(res.status).toBe(400); 169 + const body = await res.json(); 170 + expect(body.error).toMatch(/colorScheme/i); 171 + }); 172 + 173 + it("returns 400 when colorScheme is missing", async () => { 174 + const res = await app.request("/api/admin/themes", { 175 + method: "POST", 176 + headers: { "Content-Type": "application/json" }, 177 + body: JSON.stringify({ name: "Test", tokens: {} }), 178 + }); 179 + expect(res.status).toBe(400); 180 + }); 181 + 182 + it("returns 400 when tokens is missing", async () => { 183 + const res = await app.request("/api/admin/themes", { 184 + method: "POST", 185 + headers: { "Content-Type": "application/json" }, 186 + body: JSON.stringify({ name: "Test", colorScheme: "light" }), 187 + }); 188 + expect(res.status).toBe(400); 189 + const body = await res.json(); 190 + expect(body.error).toMatch(/tokens/i); 191 + }); 192 + 193 + it("returns 400 when tokens is an array (not an object)", async () => { 194 + const res = await app.request("/api/admin/themes", { 195 + method: "POST", 196 + headers: { "Content-Type": "application/json" }, 197 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a", "b"] }), 198 + }); 199 + expect(res.status).toBe(400); 200 + }); 201 + 202 + it("returns 400 when a token value is not a string", async () => { 203 + const res = await app.request("/api/admin/themes", { 204 + method: "POST", 205 + headers: { "Content-Type": "application/json" }, 206 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: { "color-bg": 123 } }), 207 + }); 208 + expect(res.status).toBe(400); 209 + const body = await res.json(); 210 + expect(body.error).toMatch(/tokens/i); 211 + }); 212 + 213 + it("returns 400 when a fontUrl is not HTTPS", async () => { 214 + const res = await app.request("/api/admin/themes", { 215 + method: "POST", 216 + headers: { "Content-Type": "application/json" }, 217 + body: JSON.stringify({ 218 + name: "Test", 219 + colorScheme: "light", 220 + tokens: {}, 221 + fontUrls: ["http://example.com/font.css"], 222 + }), 223 + }); 224 + expect(res.status).toBe(400); 225 + const body = await res.json(); 226 + expect(body.error).toMatch(/https/i); 227 + }); 228 + 229 + it("returns 503 when ForumAgent is not configured", async () => { 230 + ctx.forumAgent = null; 231 + const res = await app.request("/api/admin/themes", { 232 + method: "POST", 233 + headers: { "Content-Type": "application/json" }, 234 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 235 + }); 236 + expect(res.status).toBe(503); 237 + }); 238 + }); 239 + ``` 240 + 241 + **Step 3: Run to verify tests fail** 242 + 243 + ```bash 244 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 245 + ``` 246 + 247 + Expected: tests fail with something like `Cannot find description for POST /api/admin/themes` or 404 responses. 248 + 249 + --- 250 + 251 + ## Task 3: Implement `POST /api/admin/themes` 252 + 253 + **Files:** 254 + - Modify: `apps/appview/src/routes/admin.ts` 255 + 256 + **Step 1: Update imports** 257 + 258 + Add `themes, themePolicies` to the `@atbb/db` import line: 259 + 260 + ```typescript 261 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions, themes, themePolicies } from "@atbb/db"; 262 + ``` 263 + 264 + Add `or` to the `drizzle-orm` import: 265 + 266 + ```typescript 267 + import { eq, and, sql, asc, desc, count, or } from "drizzle-orm"; 268 + ``` 269 + 270 + **Step 2: Add validation helper (inline in the handler)** 271 + 272 + Add the following handler to `admin.ts` before the `return app;` at the bottom. Insert it after the DELETE `/boards/:id` handler and before the GET `/modlog` handler: 273 + 274 + ```typescript 275 + /** 276 + * POST /api/admin/themes 277 + * 278 + * Create a new theme record on Forum DID's PDS. 279 + * Writes space.atbb.forum.theme with a fresh TID rkey. 280 + * The firehose indexer creates the DB row asynchronously. 281 + */ 282 + app.post( 283 + "/themes", 284 + requireAuth(ctx), 285 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 286 + async (c) => { 287 + const { body, error: parseError } = await safeParseJsonBody(c); 288 + if (parseError) return parseError; 289 + 290 + const { name, colorScheme, tokens, cssOverrides, fontUrls } = body; 291 + 292 + if (typeof name !== "string" || name.trim().length === 0) { 293 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 294 + } 295 + if (typeof colorScheme !== "string" || (colorScheme !== "light" && colorScheme !== "dark")) { 296 + return c.json({ error: 'colorScheme is required and must be "light" or "dark"' }, 400); 297 + } 298 + if (tokens === null || tokens === undefined || typeof tokens !== "object" || Array.isArray(tokens)) { 299 + return c.json({ error: "tokens is required and must be a plain object" }, 400); 300 + } 301 + for (const [key, val] of Object.entries(tokens as Record<string, unknown>)) { 302 + if (typeof val !== "string") { 303 + return c.json({ error: `tokens["${key}"] must be a string` }, 400); 304 + } 305 + } 306 + if (cssOverrides !== undefined && typeof cssOverrides !== "string") { 307 + return c.json({ error: "cssOverrides must be a string" }, 400); 308 + } 309 + if (fontUrls !== undefined) { 310 + if (!Array.isArray(fontUrls)) { 311 + return c.json({ error: "fontUrls must be an array of strings" }, 400); 312 + } 313 + for (const url of fontUrls as unknown[]) { 314 + if (typeof url !== "string" || !url.startsWith("https://")) { 315 + return c.json({ error: "fontUrls must contain only HTTPS URLs" }, 400); 316 + } 317 + } 318 + } 319 + 320 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/themes"); 321 + if (agentError) return agentError; 322 + 323 + const rkey = TID.nextStr(); 324 + const now = new Date().toISOString(); 325 + 326 + try { 327 + const result = await agent.com.atproto.repo.putRecord({ 328 + repo: ctx.config.forumDid, 329 + collection: "space.atbb.forum.theme", 330 + rkey, 331 + record: { 332 + $type: "space.atbb.forum.theme", 333 + name: name.trim(), 334 + colorScheme, 335 + tokens, 336 + ...(typeof cssOverrides === "string" && { cssOverrides }), 337 + ...(Array.isArray(fontUrls) && { fontUrls }), 338 + createdAt: now, 339 + }, 340 + }); 341 + 342 + return c.json({ uri: result.data.uri, cid: result.data.cid }, 201); 343 + } catch (error) { 344 + return handleRouteError(c, error, "Failed to create theme", { 345 + operation: "POST /api/admin/themes", 346 + logger: ctx.logger, 347 + }); 348 + } 349 + } 350 + ); 351 + ``` 352 + 353 + **Step 3: Run tests to verify they pass** 354 + 355 + ```bash 356 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 357 + ``` 358 + 359 + Expected: POST /api/admin/themes tests all pass. 360 + 361 + **Step 4: Commit** 362 + 363 + ```bash 364 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 365 + git commit -m "feat(appview): POST /api/admin/themes — create theme on Forum PDS (ATB-57)" 366 + ``` 367 + 368 + --- 369 + 370 + ## Task 4: Write failing tests + implement `PUT /api/admin/themes/:rkey` 371 + 372 + **Files:** 373 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 374 + - Modify: `apps/appview/src/routes/admin.ts` 375 + 376 + **Step 1: Add tests for PUT** 377 + 378 + Add inside `describe.sequential("Admin Routes", ...)` in `admin.test.ts`: 379 + 380 + ```typescript 381 + describe("PUT /api/admin/themes/:rkey", () => { 382 + beforeEach(async () => { 383 + // Seed a theme row for the update tests 384 + await ctx.db.insert(themes).values({ 385 + did: ctx.config.forumDid, 386 + rkey: "3lblputtest1", 387 + cid: "bafyputtest", 388 + name: "Original Name", 389 + colorScheme: "light", 390 + tokens: { "color-bg": "#ffffff" }, 391 + cssOverrides: ".btn { font-weight: 700; }", 392 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"], 393 + createdAt: new Date("2026-01-01T00:00:00Z"), 394 + indexedAt: new Date(), 395 + }); 396 + }); 397 + 398 + it("updates theme and returns 200 with uri and cid", async () => { 399 + const res = await app.request("/api/admin/themes/3lblputtest1", { 400 + method: "PUT", 401 + headers: { "Content-Type": "application/json" }, 402 + body: JSON.stringify({ 403 + name: "Updated Name", 404 + colorScheme: "dark", 405 + tokens: { "color-bg": "#1a1a1a" }, 406 + }), 407 + }); 408 + expect(res.status).toBe(200); 409 + const body = await res.json(); 410 + expect(body.uri).toBeDefined(); 411 + expect(body.cid).toBeDefined(); 412 + }); 413 + 414 + it("preserves existing cssOverrides when not provided in request", async () => { 415 + const res = await app.request("/api/admin/themes/3lblputtest1", { 416 + method: "PUT", 417 + headers: { "Content-Type": "application/json" }, 418 + body: JSON.stringify({ 419 + name: "Updated Name", 420 + colorScheme: "light", 421 + tokens: { "color-bg": "#f0f0f0" }, 422 + // cssOverrides intentionally omitted 423 + }), 424 + }); 425 + expect(res.status).toBe(200); 426 + const call = mockPutRecord.mock.calls[0][0]; 427 + expect(call.record.cssOverrides).toBe(".btn { font-weight: 700; }"); 428 + }); 429 + 430 + it("preserves existing fontUrls when not provided in request", async () => { 431 + const res = await app.request("/api/admin/themes/3lblputtest1", { 432 + method: "PUT", 433 + headers: { "Content-Type": "application/json" }, 434 + body: JSON.stringify({ 435 + name: "Updated Name", 436 + colorScheme: "light", 437 + tokens: {}, 438 + // fontUrls intentionally omitted 439 + }), 440 + }); 441 + expect(res.status).toBe(200); 442 + const call = mockPutRecord.mock.calls[0][0]; 443 + expect(call.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 444 + }); 445 + 446 + it("preserves original createdAt in the PDS record", async () => { 447 + const res = await app.request("/api/admin/themes/3lblputtest1", { 448 + method: "PUT", 449 + headers: { "Content-Type": "application/json" }, 450 + body: JSON.stringify({ name: "Updated", colorScheme: "light", tokens: {} }), 451 + }); 452 + expect(res.status).toBe(200); 453 + const call = mockPutRecord.mock.calls[0][0]; 454 + expect(call.record.createdAt).toBe("2026-01-01T00:00:00.000Z"); 455 + }); 456 + 457 + it("returns 404 for unknown rkey", async () => { 458 + const res = await app.request("/api/admin/themes/nonexistent", { 459 + method: "PUT", 460 + headers: { "Content-Type": "application/json" }, 461 + body: JSON.stringify({ name: "X", colorScheme: "light", tokens: {} }), 462 + }); 463 + expect(res.status).toBe(404); 464 + }); 465 + 466 + it("returns 400 when name is missing", async () => { 467 + const res = await app.request("/api/admin/themes/3lblputtest1", { 468 + method: "PUT", 469 + headers: { "Content-Type": "application/json" }, 470 + body: JSON.stringify({ colorScheme: "light", tokens: {} }), 471 + }); 472 + expect(res.status).toBe(400); 473 + }); 474 + 475 + it("returns 400 when colorScheme is invalid", async () => { 476 + const res = await app.request("/api/admin/themes/3lblputtest1", { 477 + method: "PUT", 478 + headers: { "Content-Type": "application/json" }, 479 + body: JSON.stringify({ name: "Test", colorScheme: "sepia", tokens: {} }), 480 + }); 481 + expect(res.status).toBe(400); 482 + }); 483 + 484 + it("returns 400 when tokens is an array", async () => { 485 + const res = await app.request("/api/admin/themes/3lblputtest1", { 486 + method: "PUT", 487 + headers: { "Content-Type": "application/json" }, 488 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a"] }), 489 + }); 490 + expect(res.status).toBe(400); 491 + }); 492 + }); 493 + ``` 494 + 495 + **Step 2: Run to verify tests fail** 496 + 497 + ```bash 498 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 499 + ``` 500 + 501 + Expected: PUT tests fail with 404 (endpoint doesn't exist yet). 502 + 503 + **Step 3: Implement PUT handler in `admin.ts`** 504 + 505 + Add after the POST `/themes` handler: 506 + 507 + ```typescript 508 + /** 509 + * PUT /api/admin/themes/:rkey 510 + * 511 + * Update an existing theme. Fetches the existing row from DB to 512 + * preserve createdAt and fall back optional fields not in the request. 513 + * The firehose indexer updates the DB row asynchronously. 514 + */ 515 + app.put( 516 + "/themes/:rkey", 517 + requireAuth(ctx), 518 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 519 + async (c) => { 520 + const themeRkey = c.req.param("rkey").trim(); 521 + 522 + const { body, error: parseError } = await safeParseJsonBody(c); 523 + if (parseError) return parseError; 524 + 525 + const { name, colorScheme, tokens, cssOverrides, fontUrls } = body; 526 + 527 + if (typeof name !== "string" || name.trim().length === 0) { 528 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 529 + } 530 + if (typeof colorScheme !== "string" || (colorScheme !== "light" && colorScheme !== "dark")) { 531 + return c.json({ error: 'colorScheme is required and must be "light" or "dark"' }, 400); 532 + } 533 + if (tokens === null || tokens === undefined || typeof tokens !== "object" || Array.isArray(tokens)) { 534 + return c.json({ error: "tokens is required and must be a plain object" }, 400); 535 + } 536 + for (const [key, val] of Object.entries(tokens as Record<string, unknown>)) { 537 + if (typeof val !== "string") { 538 + return c.json({ error: `tokens["${key}"] must be a string` }, 400); 539 + } 540 + } 541 + if (cssOverrides !== undefined && typeof cssOverrides !== "string") { 542 + return c.json({ error: "cssOverrides must be a string" }, 400); 543 + } 544 + if (fontUrls !== undefined) { 545 + if (!Array.isArray(fontUrls)) { 546 + return c.json({ error: "fontUrls must be an array of strings" }, 400); 547 + } 548 + for (const url of fontUrls as unknown[]) { 549 + if (typeof url !== "string" || !url.startsWith("https://")) { 550 + return c.json({ error: "fontUrls must contain only HTTPS URLs" }, 400); 551 + } 552 + } 553 + } 554 + 555 + let theme: typeof themes.$inferSelect; 556 + try { 557 + const [row] = await ctx.db 558 + .select() 559 + .from(themes) 560 + .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, themeRkey))) 561 + .limit(1); 562 + 563 + if (!row) { 564 + return c.json({ error: "Theme not found" }, 404); 565 + } 566 + theme = row; 567 + } catch (error) { 568 + return handleRouteError(c, error, "Failed to look up theme", { 569 + operation: "PUT /api/admin/themes/:rkey", 570 + logger: ctx.logger, 571 + themeRkey, 572 + }); 573 + } 574 + 575 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/themes/:rkey"); 576 + if (agentError) return agentError; 577 + 578 + // putRecord is a full replacement — fall back to existing values for 579 + // optional fields not provided in the request body, to avoid data loss. 580 + const resolvedCssOverrides = typeof cssOverrides === "string" ? cssOverrides : theme.cssOverrides; 581 + const resolvedFontUrls = Array.isArray(fontUrls) ? fontUrls : (theme.fontUrls as string[] | null); 582 + 583 + try { 584 + const result = await agent.com.atproto.repo.putRecord({ 585 + repo: ctx.config.forumDid, 586 + collection: "space.atbb.forum.theme", 587 + rkey: theme.rkey, 588 + record: { 589 + $type: "space.atbb.forum.theme", 590 + name: name.trim(), 591 + colorScheme, 592 + tokens, 593 + ...(resolvedCssOverrides != null && { cssOverrides: resolvedCssOverrides }), 594 + ...(resolvedFontUrls != null && { fontUrls: resolvedFontUrls }), 595 + createdAt: theme.createdAt.toISOString(), 596 + updatedAt: new Date().toISOString(), 597 + }, 598 + }); 599 + 600 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 601 + } catch (error) { 602 + return handleRouteError(c, error, "Failed to update theme", { 603 + operation: "PUT /api/admin/themes/:rkey", 604 + logger: ctx.logger, 605 + themeRkey, 606 + }); 607 + } 608 + } 609 + ); 610 + ``` 611 + 612 + **Step 4: Run tests to verify they pass** 613 + 614 + ```bash 615 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 616 + ``` 617 + 618 + Expected: all PUT tests pass. 619 + 620 + **Step 5: Commit** 621 + 622 + ```bash 623 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 624 + git commit -m "feat(appview): PUT /api/admin/themes/:rkey — update theme on Forum PDS (ATB-57)" 625 + ``` 626 + 627 + --- 628 + 629 + ## Task 5: Write failing tests + implement `DELETE /api/admin/themes/:rkey` 630 + 631 + **Files:** 632 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 633 + - Modify: `apps/appview/src/routes/admin.ts` 634 + 635 + **Step 1: Add tests** 636 + 637 + Import `themePolicies, themePolicyAvailableThemes` at the top of `admin.test.ts`: 638 + 639 + ```typescript 640 + import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions, themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 641 + ``` 642 + 643 + Then add inside `describe.sequential("Admin Routes", ...)`: 644 + 645 + ```typescript 646 + describe("DELETE /api/admin/themes/:rkey", () => { 647 + const themeRkey = "3lbldeltest1"; 648 + const themeUri = `at://${ctx?.config?.forumDid ?? "did:plc:test-forum"}/space.atbb.forum.theme/${themeRkey}`; 649 + 650 + beforeEach(async () => { 651 + await ctx.db.insert(themes).values({ 652 + did: ctx.config.forumDid, 653 + rkey: themeRkey, 654 + cid: "bafydeltest", 655 + name: "Theme To Delete", 656 + colorScheme: "light", 657 + tokens: { "color-bg": "#ffffff" }, 658 + createdAt: new Date(), 659 + indexedAt: new Date(), 660 + }); 661 + }); 662 + 663 + it("deletes theme and returns 200 with success: true", async () => { 664 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 665 + method: "DELETE", 666 + }); 667 + expect(res.status).toBe(200); 668 + const body = await res.json(); 669 + expect(body.success).toBe(true); 670 + expect(mockDeleteRecord).toHaveBeenCalledOnce(); 671 + }); 672 + 673 + it("returns 404 for unknown rkey", async () => { 674 + const res = await app.request("/api/admin/themes/doesnotexist", { 675 + method: "DELETE", 676 + }); 677 + expect(res.status).toBe(404); 678 + }); 679 + 680 + it("returns 409 when theme is the defaultLightTheme in policy", async () => { 681 + const [policy] = await ctx.db.insert(themePolicies).values({ 682 + did: ctx.config.forumDid, 683 + rkey: "self", 684 + cid: "bafypolicydel", 685 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 686 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 687 + allowUserChoice: true, 688 + indexedAt: new Date(), 689 + }).returning(); 690 + 691 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 692 + method: "DELETE", 693 + }); 694 + expect(res.status).toBe(409); 695 + const body = await res.json(); 696 + expect(body.error).toMatch(/default/i); 697 + }); 698 + 699 + it("returns 409 when theme is the defaultDarkTheme in policy", async () => { 700 + await ctx.db.insert(themePolicies).values({ 701 + did: ctx.config.forumDid, 702 + rkey: "self", 703 + cid: "bafypolicydel2", 704 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 705 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 706 + allowUserChoice: true, 707 + indexedAt: new Date(), 708 + }); 709 + 710 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 711 + method: "DELETE", 712 + }); 713 + expect(res.status).toBe(409); 714 + }); 715 + 716 + it("deletes successfully when theme exists in policy availableThemes but not as a default", async () => { 717 + // A theme can be in availableThemes without being a default — this should NOT block deletion 718 + // (the 409 guard only checks defaultLightThemeUri / defaultDarkThemeUri columns) 719 + const [policy] = await ctx.db.insert(themePolicies).values({ 720 + did: ctx.config.forumDid, 721 + rkey: "self", 722 + cid: "bafypolicyavail", 723 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 724 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 725 + allowUserChoice: true, 726 + indexedAt: new Date(), 727 + }).returning(); 728 + await ctx.db.insert(themePolicyAvailableThemes).values({ 729 + policyId: policy.id, 730 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 731 + themeCid: "bafydeltest", 732 + }); 733 + 734 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 735 + method: "DELETE", 736 + }); 737 + expect(res.status).toBe(200); 738 + }); 739 + }); 740 + ``` 741 + 742 + **Step 2: Run to verify tests fail** 743 + 744 + ```bash 745 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 746 + ``` 747 + 748 + Expected: DELETE tests fail. 749 + 750 + **Step 3: Implement DELETE handler in `admin.ts`** 751 + 752 + Add after the PUT `/themes/:rkey` handler: 753 + 754 + ```typescript 755 + /** 756 + * DELETE /api/admin/themes/:rkey 757 + * 758 + * Delete a theme. Pre-flight: refuses with 409 if the theme is set as 759 + * defaultLightTheme or defaultDarkTheme in the theme policy. 760 + * The firehose indexer removes the DB row asynchronously. 761 + */ 762 + app.delete( 763 + "/themes/:rkey", 764 + requireAuth(ctx), 765 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 766 + async (c) => { 767 + const themeRkey = c.req.param("rkey").trim(); 768 + 769 + let theme: typeof themes.$inferSelect; 770 + try { 771 + const [row] = await ctx.db 772 + .select() 773 + .from(themes) 774 + .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, themeRkey))) 775 + .limit(1); 776 + 777 + if (!row) { 778 + return c.json({ error: "Theme not found" }, 404); 779 + } 780 + theme = row; 781 + } catch (error) { 782 + return handleRouteError(c, error, "Failed to look up theme", { 783 + operation: "DELETE /api/admin/themes/:rkey", 784 + logger: ctx.logger, 785 + themeRkey, 786 + }); 787 + } 788 + 789 + // Pre-flight conflict check: refuse if this theme is a policy default 790 + const themeUri = `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`; 791 + try { 792 + const [conflictingPolicy] = await ctx.db 793 + .select({ id: themePolicies.id }) 794 + .from(themePolicies) 795 + .where( 796 + and( 797 + eq(themePolicies.did, ctx.config.forumDid), 798 + or( 799 + eq(themePolicies.defaultLightThemeUri, themeUri), 800 + eq(themePolicies.defaultDarkThemeUri, themeUri) 801 + ) 802 + ) 803 + ) 804 + .limit(1); 805 + 806 + if (conflictingPolicy) { 807 + return c.json( 808 + { error: "Cannot delete a theme that is currently set as a default. Update the theme policy first." }, 809 + 409 810 + ); 811 + } 812 + } catch (error) { 813 + return handleRouteError(c, error, "Failed to check theme policy", { 814 + operation: "DELETE /api/admin/themes/:rkey", 815 + logger: ctx.logger, 816 + themeRkey, 817 + }); 818 + } 819 + 820 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/themes/:rkey"); 821 + if (agentError) return agentError; 822 + 823 + try { 824 + await agent.com.atproto.repo.deleteRecord({ 825 + repo: ctx.config.forumDid, 826 + collection: "space.atbb.forum.theme", 827 + rkey: theme.rkey, 828 + }); 829 + 830 + return c.json({ success: true }); 831 + } catch (error) { 832 + return handleRouteError(c, error, "Failed to delete theme", { 833 + operation: "DELETE /api/admin/themes/:rkey", 834 + logger: ctx.logger, 835 + themeRkey, 836 + }); 837 + } 838 + } 839 + ); 840 + ``` 841 + 842 + **Step 4: Run tests to verify they pass** 843 + 844 + ```bash 845 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 846 + ``` 847 + 848 + Expected: all DELETE tests pass. 849 + 850 + **Step 5: Commit** 851 + 852 + ```bash 853 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 854 + git commit -m "feat(appview): DELETE /api/admin/themes/:rkey — delete theme, 409 if default (ATB-57)" 855 + ``` 856 + 857 + --- 858 + 859 + ## Task 6: Write failing tests + implement `PUT /api/admin/theme-policy` 860 + 861 + **Files:** 862 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 863 + - Modify: `apps/appview/src/routes/admin.ts` 864 + 865 + **Step 1: Add tests** 866 + 867 + Add inside `describe.sequential("Admin Routes", ...)`: 868 + 869 + ```typescript 870 + describe("PUT /api/admin/theme-policy", () => { 871 + const lightUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbllight1`; 872 + const darkUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbldark11`; 873 + 874 + const validBody = { 875 + availableThemes: [ 876 + { uri: lightUri, cid: "bafylight" }, 877 + { uri: darkUri, cid: "bafydark" }, 878 + ], 879 + defaultLightThemeUri: lightUri, 880 + defaultDarkThemeUri: darkUri, 881 + allowUserChoice: true, 882 + }; 883 + 884 + it("creates policy (upsert) and returns 200 with uri and cid", async () => { 885 + const res = await app.request("/api/admin/theme-policy", { 886 + method: "PUT", 887 + headers: { "Content-Type": "application/json" }, 888 + body: JSON.stringify(validBody), 889 + }); 890 + expect(res.status).toBe(200); 891 + const body = await res.json(); 892 + expect(body.uri).toBeDefined(); 893 + expect(body.cid).toBeDefined(); 894 + expect(mockPutRecord).toHaveBeenCalledOnce(); 895 + }); 896 + 897 + it("writes PDS record with themeRef wrapper structure", async () => { 898 + await app.request("/api/admin/theme-policy", { 899 + method: "PUT", 900 + headers: { "Content-Type": "application/json" }, 901 + body: JSON.stringify(validBody), 902 + }); 903 + const call = mockPutRecord.mock.calls[0][0]; 904 + expect(call.record.$type).toBe("space.atbb.forum.themePolicy"); 905 + expect(call.rkey).toBe("self"); 906 + // availableThemes wrapped in { theme: { uri, cid } } 907 + expect(call.record.availableThemes[0]).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 908 + expect(call.record.defaultLightTheme).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 909 + expect(call.record.defaultDarkTheme).toEqual({ theme: { uri: darkUri, cid: "bafydark" } }); 910 + expect(call.record.allowUserChoice).toBe(true); 911 + }); 912 + 913 + it("defaults allowUserChoice to true when not provided", async () => { 914 + const { allowUserChoice: _, ...bodyWithout } = validBody; 915 + await app.request("/api/admin/theme-policy", { 916 + method: "PUT", 917 + headers: { "Content-Type": "application/json" }, 918 + body: JSON.stringify(bodyWithout), 919 + }); 920 + const call = mockPutRecord.mock.calls[0][0]; 921 + expect(call.record.allowUserChoice).toBe(true); 922 + }); 923 + 924 + it("returns 400 when availableThemes is missing", async () => { 925 + const { availableThemes: _, ...bodyWithout } = validBody; 926 + const res = await app.request("/api/admin/theme-policy", { 927 + method: "PUT", 928 + headers: { "Content-Type": "application/json" }, 929 + body: JSON.stringify(bodyWithout), 930 + }); 931 + expect(res.status).toBe(400); 932 + const body = await res.json(); 933 + expect(body.error).toMatch(/availableThemes/i); 934 + }); 935 + 936 + it("returns 400 when availableThemes is empty array", async () => { 937 + const res = await app.request("/api/admin/theme-policy", { 938 + method: "PUT", 939 + headers: { "Content-Type": "application/json" }, 940 + body: JSON.stringify({ ...validBody, availableThemes: [] }), 941 + }); 942 + expect(res.status).toBe(400); 943 + }); 944 + 945 + it("returns 400 when availableThemes item is missing cid", async () => { 946 + const res = await app.request("/api/admin/theme-policy", { 947 + method: "PUT", 948 + headers: { "Content-Type": "application/json" }, 949 + body: JSON.stringify({ 950 + ...validBody, 951 + availableThemes: [{ uri: lightUri }], // missing cid 952 + defaultLightThemeUri: lightUri, 953 + defaultDarkThemeUri: lightUri, 954 + }), 955 + }); 956 + expect(res.status).toBe(400); 957 + }); 958 + 959 + it("returns 400 when defaultLightThemeUri is not in availableThemes", async () => { 960 + const res = await app.request("/api/admin/theme-policy", { 961 + method: "PUT", 962 + headers: { "Content-Type": "application/json" }, 963 + body: JSON.stringify({ 964 + ...validBody, 965 + defaultLightThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist", 966 + }), 967 + }); 968 + expect(res.status).toBe(400); 969 + const body = await res.json(); 970 + expect(body.error).toMatch(/defaultLightThemeUri/i); 971 + }); 972 + 973 + it("returns 400 when defaultDarkThemeUri is not in availableThemes", async () => { 974 + const res = await app.request("/api/admin/theme-policy", { 975 + method: "PUT", 976 + headers: { "Content-Type": "application/json" }, 977 + body: JSON.stringify({ 978 + ...validBody, 979 + defaultDarkThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist", 980 + }), 981 + }); 982 + expect(res.status).toBe(400); 983 + const body = await res.json(); 984 + expect(body.error).toMatch(/defaultDarkThemeUri/i); 985 + }); 986 + 987 + it("returns 400 when defaultLightThemeUri is missing", async () => { 988 + const { defaultLightThemeUri: _, ...bodyWithout } = validBody; 989 + const res = await app.request("/api/admin/theme-policy", { 990 + method: "PUT", 991 + headers: { "Content-Type": "application/json" }, 992 + body: JSON.stringify(bodyWithout), 993 + }); 994 + expect(res.status).toBe(400); 995 + }); 996 + 997 + it("returns 400 when defaultDarkThemeUri is missing", async () => { 998 + const { defaultDarkThemeUri: _, ...bodyWithout } = validBody; 999 + const res = await app.request("/api/admin/theme-policy", { 1000 + method: "PUT", 1001 + headers: { "Content-Type": "application/json" }, 1002 + body: JSON.stringify(bodyWithout), 1003 + }); 1004 + expect(res.status).toBe(400); 1005 + }); 1006 + 1007 + it("returns 503 when ForumAgent is not configured", async () => { 1008 + ctx.forumAgent = null; 1009 + const res = await app.request("/api/admin/theme-policy", { 1010 + method: "PUT", 1011 + headers: { "Content-Type": "application/json" }, 1012 + body: JSON.stringify(validBody), 1013 + }); 1014 + expect(res.status).toBe(503); 1015 + }); 1016 + }); 1017 + ``` 1018 + 1019 + **Step 2: Run to verify tests fail** 1020 + 1021 + ```bash 1022 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 1023 + ``` 1024 + 1025 + Expected: PUT /admin/theme-policy tests fail with 404. 1026 + 1027 + **Step 3: Implement PUT /theme-policy handler in `admin.ts`** 1028 + 1029 + Add after the DELETE `/themes/:rkey` handler (and before GET `/modlog`): 1030 + 1031 + ```typescript 1032 + /** 1033 + * PUT /api/admin/theme-policy 1034 + * 1035 + * Create or update the themePolicy singleton (rkey: "self") on Forum DID's PDS. 1036 + * Upsert semantics: works whether or not a policy record exists yet. 1037 + * The firehose indexer creates/updates the DB row asynchronously. 1038 + */ 1039 + app.put( 1040 + "/theme-policy", 1041 + requireAuth(ctx), 1042 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 1043 + async (c) => { 1044 + const { body, error: parseError } = await safeParseJsonBody(c); 1045 + if (parseError) return parseError; 1046 + 1047 + const { availableThemes, defaultLightThemeUri, defaultDarkThemeUri, allowUserChoice } = body; 1048 + 1049 + if (!Array.isArray(availableThemes) || availableThemes.length === 0) { 1050 + return c.json({ error: "availableThemes is required and must be a non-empty array" }, 400); 1051 + } 1052 + for (const t of availableThemes as unknown[]) { 1053 + if ( 1054 + typeof (t as any)?.uri !== "string" || 1055 + typeof (t as any)?.cid !== "string" 1056 + ) { 1057 + return c.json({ error: "Each availableThemes entry must have uri and cid string fields" }, 400); 1058 + } 1059 + } 1060 + 1061 + if (typeof defaultLightThemeUri !== "string" || !defaultLightThemeUri.startsWith("at://")) { 1062 + return c.json({ error: "defaultLightThemeUri is required and must be an AT-URI" }, 400); 1063 + } 1064 + if (typeof defaultDarkThemeUri !== "string" || !defaultDarkThemeUri.startsWith("at://")) { 1065 + return c.json({ error: "defaultDarkThemeUri is required and must be an AT-URI" }, 400); 1066 + } 1067 + 1068 + const availableUris = (availableThemes as Array<{ uri: string; cid: string }>).map((t) => t.uri); 1069 + if (!availableUris.includes(defaultLightThemeUri)) { 1070 + return c.json({ error: "defaultLightThemeUri must be present in availableThemes" }, 400); 1071 + } 1072 + if (!availableUris.includes(defaultDarkThemeUri)) { 1073 + return c.json({ error: "defaultDarkThemeUri must be present in availableThemes" }, 400); 1074 + } 1075 + 1076 + const resolvedAllowUserChoice = typeof allowUserChoice === "boolean" ? allowUserChoice : true; 1077 + 1078 + const typedAvailableThemes = availableThemes as Array<{ uri: string; cid: string }>; 1079 + const lightTheme = typedAvailableThemes.find((t) => t.uri === defaultLightThemeUri)!; 1080 + const darkTheme = typedAvailableThemes.find((t) => t.uri === defaultDarkThemeUri)!; 1081 + 1082 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/theme-policy"); 1083 + if (agentError) return agentError; 1084 + 1085 + try { 1086 + const result = await agent.com.atproto.repo.putRecord({ 1087 + repo: ctx.config.forumDid, 1088 + collection: "space.atbb.forum.themePolicy", 1089 + rkey: "self", 1090 + record: { 1091 + $type: "space.atbb.forum.themePolicy", 1092 + availableThemes: typedAvailableThemes.map((t) => ({ 1093 + theme: { uri: t.uri, cid: t.cid }, 1094 + })), 1095 + defaultLightTheme: { theme: { uri: lightTheme.uri, cid: lightTheme.cid } }, 1096 + defaultDarkTheme: { theme: { uri: darkTheme.uri, cid: darkTheme.cid } }, 1097 + allowUserChoice: resolvedAllowUserChoice, 1098 + updatedAt: new Date().toISOString(), 1099 + }, 1100 + }); 1101 + 1102 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 1103 + } catch (error) { 1104 + return handleRouteError(c, error, "Failed to update theme policy", { 1105 + operation: "PUT /api/admin/theme-policy", 1106 + logger: ctx.logger, 1107 + }); 1108 + } 1109 + } 1110 + ); 1111 + ``` 1112 + 1113 + **Step 4: Run tests to verify they pass** 1114 + 1115 + ```bash 1116 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 1117 + ``` 1118 + 1119 + Expected: all theme-policy tests pass. 1120 + 1121 + **Step 5: Run full test suite** 1122 + 1123 + ```bash 1124 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run 1125 + ``` 1126 + 1127 + Expected: all tests pass. 1128 + 1129 + **Step 6: Commit** 1130 + 1131 + ```bash 1132 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 1133 + git commit -m "feat(appview): PUT /api/admin/theme-policy — upsert policy singleton on Forum PDS (ATB-57)" 1134 + ``` 1135 + 1136 + --- 1137 + 1138 + ## Task 7: Add Bruno collection files 1139 + 1140 + **Files:** 1141 + - Create: `bruno/AppView API/Admin Themes/Create Theme.bru` 1142 + - Create: `bruno/AppView API/Admin Themes/Update Theme.bru` 1143 + - Create: `bruno/AppView API/Admin Themes/Delete Theme.bru` 1144 + - Create: `bruno/AppView API/Admin Themes/Update Theme Policy.bru` 1145 + 1146 + **Step 1: Create directory and files** 1147 + 1148 + Create `bruno/AppView API/Admin Themes/Create Theme.bru`: 1149 + 1150 + ```bru 1151 + meta { 1152 + name: Create Theme 1153 + type: http 1154 + seq: 1 1155 + } 1156 + 1157 + post { 1158 + url: {{appview_url}}/api/admin/themes 1159 + } 1160 + 1161 + body:json { 1162 + { 1163 + "name": "Neobrutal Light", 1164 + "colorScheme": "light", 1165 + "tokens": { 1166 + "color-bg": "#f5f0e8", 1167 + "color-text": "#1a1a1a", 1168 + "color-primary": "#ff5c00" 1169 + }, 1170 + "fontUrls": ["https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700"] 1171 + } 1172 + } 1173 + 1174 + assert { 1175 + res.status: eq 201 1176 + res.body.uri: isDefined 1177 + res.body.cid: isDefined 1178 + } 1179 + 1180 + docs { 1181 + Create a new theme record on the Forum DID's PDS. 1182 + The firehose indexer creates the DB row asynchronously. 1183 + 1184 + **Requires:** space.atbb.permission.manageThemes 1185 + 1186 + Body: 1187 + - name (required): Theme display name, non-empty 1188 + - colorScheme (required): "light" or "dark" 1189 + - tokens (required): Plain object of CSS design token key-value pairs. Values must be strings. 1190 + - cssOverrides (optional): Raw CSS string for structural overrides (not rendered until ATB-62 sanitization ships) 1191 + - fontUrls (optional): Array of HTTPS URLs for font stylesheets 1192 + 1193 + Returns (201): 1194 + { 1195 + "uri": "at://did:plc:.../space.atbb.forum.theme/abc123", 1196 + "cid": "bafyrei..." 1197 + } 1198 + 1199 + Error codes: 1200 + - 400: Missing name/colorScheme/tokens, invalid colorScheme, non-HTTPS fontUrl, token value not a string, malformed JSON 1201 + - 401: Not authenticated 1202 + - 403: Missing manageThemes permission 1203 + - 503: ForumAgent not configured or PDS network error 1204 + } 1205 + ``` 1206 + 1207 + Create `bruno/AppView API/Admin Themes/Update Theme.bru`: 1208 + 1209 + ```bru 1210 + meta { 1211 + name: Update Theme 1212 + type: http 1213 + seq: 2 1214 + } 1215 + 1216 + put { 1217 + url: {{appview_url}}/api/admin/themes/{{theme_rkey}} 1218 + } 1219 + 1220 + body:json { 1221 + { 1222 + "name": "Neobrutal Light (Updated)", 1223 + "colorScheme": "light", 1224 + "tokens": { 1225 + "color-bg": "#f5f0e8", 1226 + "color-text": "#1a1a1a", 1227 + "color-primary": "#ff5c00" 1228 + } 1229 + } 1230 + } 1231 + 1232 + assert { 1233 + res.status: eq 200 1234 + res.body.uri: isDefined 1235 + res.body.cid: isDefined 1236 + } 1237 + 1238 + docs { 1239 + Update an existing theme record. Full replacement of the PDS record. 1240 + Optional fields (cssOverrides, fontUrls) fall back to their existing values 1241 + when omitted from the request body. 1242 + 1243 + **Requires:** space.atbb.permission.manageThemes 1244 + 1245 + Path params: 1246 + - rkey: Theme record key (TID) 1247 + 1248 + Body: same as Create Theme (all fields). 1249 + 1250 + Returns (200): 1251 + { 1252 + "uri": "at://did:plc:.../space.atbb.forum.theme/abc123", 1253 + "cid": "bafyrei..." 1254 + } 1255 + 1256 + Error codes: 1257 + - 400: Invalid input (same as Create Theme) 1258 + - 401: Not authenticated 1259 + - 403: Missing manageThemes permission 1260 + - 404: Theme not found 1261 + - 503: ForumAgent not configured or PDS network error 1262 + } 1263 + ``` 1264 + 1265 + Create `bruno/AppView API/Admin Themes/Delete Theme.bru`: 1266 + 1267 + ```bru 1268 + meta { 1269 + name: Delete Theme 1270 + type: http 1271 + seq: 3 1272 + } 1273 + 1274 + delete { 1275 + url: {{appview_url}}/api/admin/themes/{{theme_rkey}} 1276 + } 1277 + 1278 + assert { 1279 + res.status: eq 200 1280 + res.body.success: eq true 1281 + } 1282 + 1283 + docs { 1284 + Delete a theme record. Fails with 409 if the theme is currently set as 1285 + the defaultLightTheme or defaultDarkTheme in the theme policy. 1286 + 1287 + **Requires:** space.atbb.permission.manageThemes 1288 + 1289 + Path params: 1290 + - rkey: Theme record key (TID) 1291 + 1292 + Returns (200): 1293 + { 1294 + "success": true 1295 + } 1296 + 1297 + Error codes: 1298 + - 401: Not authenticated 1299 + - 403: Missing manageThemes permission 1300 + - 404: Theme not found 1301 + - 409: Theme is the current defaultLightTheme or defaultDarkTheme — update theme policy first 1302 + - 503: ForumAgent not configured or PDS network error 1303 + } 1304 + ``` 1305 + 1306 + Create `bruno/AppView API/Admin Themes/Update Theme Policy.bru`: 1307 + 1308 + ```bru 1309 + meta { 1310 + name: Update Theme Policy 1311 + type: http 1312 + seq: 4 1313 + } 1314 + 1315 + put { 1316 + url: {{appview_url}}/api/admin/theme-policy 1317 + } 1318 + 1319 + body:json { 1320 + { 1321 + "availableThemes": [ 1322 + { "uri": "at://did:plc:example/space.atbb.forum.theme/3lbllight1", "cid": "bafylight" }, 1323 + { "uri": "at://did:plc:example/space.atbb.forum.theme/3lbldark11", "cid": "bafydark" } 1324 + ], 1325 + "defaultLightThemeUri": "at://did:plc:example/space.atbb.forum.theme/3lbllight1", 1326 + "defaultDarkThemeUri": "at://did:plc:example/space.atbb.forum.theme/3lbldark11", 1327 + "allowUserChoice": true 1328 + } 1329 + } 1330 + 1331 + assert { 1332 + res.status: eq 200 1333 + res.body.uri: isDefined 1334 + res.body.cid: isDefined 1335 + } 1336 + 1337 + docs { 1338 + Create or update the themePolicy singleton on the Forum DID's PDS. 1339 + Uses upsert semantics: works whether or not a policy record exists yet. 1340 + 1341 + **Requires:** space.atbb.permission.manageThemes 1342 + 1343 + Body: 1344 + - availableThemes (required): Non-empty array of { uri, cid } theme references. 1345 + Both defaultLightThemeUri and defaultDarkThemeUri must be present in this list. 1346 + - defaultLightThemeUri (required): AT-URI of the default light-mode theme. 1347 + Must be in availableThemes. 1348 + - defaultDarkThemeUri (required): AT-URI of the default dark-mode theme. 1349 + Must be in availableThemes. 1350 + - allowUserChoice (optional, default true): Whether users can pick their own theme. 1351 + 1352 + Returns (200): 1353 + { 1354 + "uri": "at://did:plc:.../space.atbb.forum.themePolicy/self", 1355 + "cid": "bafyrei..." 1356 + } 1357 + 1358 + Error codes: 1359 + - 400: Missing/empty availableThemes, missing defaultLightThemeUri/defaultDarkThemeUri, 1360 + default URI not in availableThemes list, malformed JSON 1361 + - 401: Not authenticated 1362 + - 403: Missing manageThemes permission 1363 + - 503: ForumAgent not configured or PDS network error 1364 + } 1365 + ``` 1366 + 1367 + **Step 2: Verify the collection directory exists and files are created** 1368 + 1369 + ```bash 1370 + ls "bruno/AppView API/Admin Themes/" 1371 + ``` 1372 + 1373 + Expected: 4 `.bru` files listed. 1374 + 1375 + **Step 3: Run full test suite one final time** 1376 + 1377 + ```bash 1378 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run 1379 + ``` 1380 + 1381 + Expected: all tests pass. 1382 + 1383 + **Step 4: Commit** 1384 + 1385 + ```bash 1386 + git add "bruno/AppView API/Admin Themes/" 1387 + git commit -m "docs(bruno): add Admin Themes collection for ATB-57 write endpoints" 1388 + ``` 1389 + 1390 + --- 1391 + 1392 + ## Task 8: Linear + plan doc update 1393 + 1394 + **Step 1: Mark plan doc complete** 1395 + 1396 + In `docs/plans/2026-03-02-atb-57-theme-write-api.md`, update the status line to: 1397 + ``` 1398 + **Status:** Complete (ATB-57) 1399 + ``` 1400 + 1401 + Rename/move the plan doc to `docs/plans/complete/`: 1402 + ```bash 1403 + mv docs/plans/2026-03-02-atb-57-theme-write-api.md docs/plans/complete/2026-03-02-atb-57-theme-write-api.md 1404 + mv docs/plans/2026-03-02-theme-write-api-design.md docs/plans/complete/2026-03-02-theme-write-api-design.md 1405 + ``` 1406 + 1407 + **Step 2: Commit plan doc move** 1408 + 1409 + ```bash 1410 + git add docs/plans/ 1411 + git commit -m "docs: mark ATB-57 plan docs complete, move to docs/plans/complete/" 1412 + ``` 1413 + 1414 + **Step 3: Update Linear** 1415 + 1416 + - Set ATB-57 status to **Done** 1417 + - Add a comment: "Implemented POST/PUT/DELETE /api/admin/themes and PUT /api/admin/theme-policy in admin.ts. Added manageThemes permission to Admin role in seed-roles.ts. Bruno collection added under Admin Themes/. All tests pass."