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

finished plans

-1595
-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."
-178
docs/plans/2026-03-02-theme-write-api-design.md
··· 1 - # Theme Write API Endpoints — Design 2 - 3 - **Linear:** ATB-57 4 - **Date:** 2026-03-02 5 - **Status:** Approved, ready for implementation 6 - 7 - --- 8 - 9 - ## Context 10 - 11 - The AppView needs write endpoints so admins can create, update, and delete themes, and manage the theme policy. These follow the PDS-first pattern established by category and board management. 12 - 13 - **Depends on:** ATB-51 (theme lexicons), ATB-55 (theme read endpoints + DB tables) 14 - 15 - --- 16 - 17 - ## Route Placement 18 - 19 - All four endpoints are added to `apps/appview/src/routes/admin.ts`, alongside existing category/board write endpoints. The admin router is already mounted at `/admin` in `index.ts` — no routing changes needed. 20 - 21 - --- 22 - 23 - ## Endpoints 24 - 25 - | Method | Path | Permission | 26 - |--------|------|-----------| 27 - | `POST` | `/api/admin/themes` | `space.atbb.permission.manageThemes` | 28 - | `PUT` | `/api/admin/themes/:rkey` | `space.atbb.permission.manageThemes` | 29 - | `DELETE` | `/api/admin/themes/:rkey` | `space.atbb.permission.manageThemes` | 30 - | `PUT` | `/api/admin/theme-policy` | `space.atbb.permission.manageThemes` | 31 - 32 - --- 33 - 34 - ## Permission Changes 35 - 36 - Add `space.atbb.permission.manageThemes` to `apps/appview/src/lib/seed-roles.ts`: 37 - 38 - - **Owner**: already has `"*"` wildcard — no change 39 - - **Admin**: add `manageThemes` to the permissions array 40 - - **Moderator / Member**: no change 41 - 42 - --- 43 - 44 - ## Input Validation 45 - 46 - ### Theme (POST and PUT) 47 - 48 - | Field | Rule | 49 - |-------|------| 50 - | `name` | Required string, non-empty, ≤ 100 graphemes | 51 - | `colorScheme` | Required, must be `"light"` or `"dark"` | 52 - | `tokens` | Required, must be a non-null object; values must be strings | 53 - | `cssOverrides` | Optional string (do NOT render until ATB-62 CSS sanitization ships) | 54 - | `fontUrls` | Optional array of strings; each must start with `"https://"` | 55 - 56 - Token keys are **not** validated against a known list (lenient mode — allows custom/future tokens). 57 - 58 - ### Theme Policy (PUT) 59 - 60 - | Field | Rule | 61 - |-------|------| 62 - | `availableThemes` | Required non-empty array of `{ uri: string, cid: string }` | 63 - | `defaultLightThemeUri` | Required string; must be an AT-URI present in `availableThemes` | 64 - | `defaultDarkThemeUri` | Required string; must be an AT-URI present in `availableThemes` | 65 - | `allowUserChoice` | Optional boolean, defaults `true` | 66 - 67 - --- 68 - 69 - ## Endpoint Details 70 - 71 - ### `POST /api/admin/themes` 72 - 73 - 1. Parse and validate request body 74 - 2. Get ForumAgent (return 503 if unavailable) 75 - 3. Generate `rkey = TID.nextStr()` 76 - 4. `putRecord` on Forum DID's PDS with `collection: "space.atbb.forum.theme"` 77 - 5. Return `{ uri, cid }` with `201` 78 - 79 - Does not wait for firehose indexing — the PDS write is the authoritative action. 80 - 81 - ### `PUT /api/admin/themes/:rkey` 82 - 83 - 1. Parse and validate request body 84 - 2. Look up existing theme by `rkey` + `forumDid` in DB (404 if missing) 85 - 3. Get ForumAgent 86 - 4. `putRecord` with same rkey, preserving `createdAt` from DB row 87 - 5. Optional fields (`cssOverrides`, `fontUrls`, `description`) fall back to existing DB values if not provided in request 88 - 6. Return `{ uri, cid }` with `200` 89 - 90 - ### `DELETE /api/admin/themes/:rkey` 91 - 92 - 1. Look up theme in DB (404 if missing) 93 - 2. Pre-flight conflict check: query `theme_policies` for rows where `default_light_theme_uri` OR `default_dark_theme_uri` = this theme's AT-URI 94 - 3. Return `409` if any match 95 - 4. Get ForumAgent 96 - 5. `deleteRecord` on Forum DID's PDS 97 - 6. Return `{ success: true }` with `200` 98 - 99 - ### `PUT /api/admin/theme-policy` 100 - 101 - Upsert semantics (creates if no policy row exists yet, updates if one does). 102 - 103 - 1. Parse and validate request body 104 - 2. Validate `defaultLightThemeUri` is present in `availableThemes` (400 if not) 105 - 3. Validate `defaultDarkThemeUri` is present in `availableThemes` (400 if not) 106 - 4. Get ForumAgent 107 - 5. `putRecord` with `rkey: "self"`, `collection: "space.atbb.forum.themePolicy"` 108 - 6. PDS record structure follows the `themeRef` wrapper pattern from the lexicon: `{ theme: { uri, cid } }` 109 - 7. Return `{ uri, cid }` with `200` 110 - 111 - --- 112 - 113 - ## Error Codes 114 - 115 - | Status | Condition | 116 - |--------|-----------| 117 - | 400 | Invalid/missing input field, invalid colorScheme, non-HTTPS fontUrl, default theme not in availableThemes | 118 - | 401 | Not authenticated | 119 - | 403 | Caller lacks `manageThemes` permission | 120 - | 404 | Theme rkey not found (PUT/DELETE) | 121 - | 409 | DELETE attempted on a theme that is the current policy default | 122 - | 503 | DB or PDS connectivity error | 123 - 124 - --- 125 - 126 - ## Tests 127 - 128 - ### `POST /api/admin/themes` 129 - - Happy path: returns 201 with uri and cid 130 - - Missing `name` → 400 131 - - Empty `name` → 400 132 - - `name` too long (> 100 graphemes) → 400 133 - - Invalid `colorScheme` (not light/dark) → 400 134 - - Missing `colorScheme` → 400 135 - - `tokens` not an object → 400 136 - - Missing `tokens` → 400 137 - - Non-HTTPS fontUrl → 400 138 - - Permission denied (no manageThemes) → 403 139 - - Unauthenticated → 401 140 - - PDS/DB error → 503 141 - 142 - ### `PUT /api/admin/themes/:rkey` 143 - - Happy path: updates theme, returns 200 144 - - Partial update (no cssOverrides in body) preserves existing cssOverrides 145 - - Unknown rkey → 404 146 - - Same input validation failures as POST → 400 147 - - Permission denied → 403 148 - 149 - ### `DELETE /api/admin/themes/:rkey` 150 - - Happy path: deletes theme, returns 200 151 - - Unknown rkey → 404 152 - - Theme is defaultLightTheme in policy → 409 153 - - Theme is defaultDarkTheme in policy → 409 154 - - Permission denied → 403 155 - 156 - ### `PUT /api/admin/theme-policy` 157 - - Happy path create (no existing policy): returns 200 158 - - Happy path update (policy already exists): returns 200 159 - - `defaultLightThemeUri` not in `availableThemes` → 400 160 - - `defaultDarkThemeUri` not in `availableThemes` → 400 161 - - Missing `availableThemes` → 400 162 - - Empty `availableThemes` array → 400 163 - - Missing `defaultLightThemeUri` → 400 164 - - Missing `defaultDarkThemeUri` → 400 165 - - Permission denied → 403 166 - 167 - --- 168 - 169 - ## Bruno Collection 170 - 171 - New files in `bruno/AppView API/Admin Themes/`: 172 - 173 - - `Create Theme.bru` 174 - - `Update Theme.bru` 175 - - `Delete Theme.bru` 176 - - `Update Theme Policy.bru` 177 - 178 - All use `{{appview_url}}` for the base URL and include error code documentation.