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.

feat(appview): theme write API endpoints — POST/PUT/DELETE themes + PUT theme-policy (ATB-57) (#87)

* docs: add design doc for ATB-57 theme write API endpoints

* docs: add implementation plan for ATB-57 theme write API endpoints

* feat(appview): add manageThemes permission to Admin role (ATB-57)

* feat(appview): POST /api/admin/themes — create theme on Forum PDS (ATB-57)

* test(appview): add 401/403/PDS-failure tests for POST /api/admin/themes (ATB-57)

* feat(appview): PUT /api/admin/themes/:rkey — update theme on Forum PDS (ATB-57)

* feat(appview): DELETE /api/admin/themes/:rkey — delete theme, 409 if default (ATB-57)

* test(appview): assert 409 error body in dark-theme default check (ATB-57)

* test(appview): verify deleteRecord called with exact theme args (ATB-57)

* feat(appview): PUT /api/admin/theme-policy — upsert policy singleton on Forum PDS (ATB-57)

* test(appview): add theme-policy update path and updatedAt assertions (ATB-57)

* refactor(appview): strengthen theme-policy type guards and test assertions (ATB-57)

* docs(bruno): add Admin Themes collection for ATB-57 write endpoints

* docs: mark ATB-57 plan docs complete, move to docs/plans/complete/

* fix(appview): add 503 ForumAgent-not-authenticated tests; fix Bruno error code docs (ATB-57)

authored by

Malpercio and committed by
GitHub
7274516a 3f6a5d9a

+2928 -3
+1
apps/appview/src/lib/seed-roles.ts
··· 25 25 "space.atbb.permission.manageCategories", 26 26 "space.atbb.permission.manageRoles", 27 27 "space.atbb.permission.manageMembers", 28 + "space.atbb.permission.manageThemes", 28 29 "space.atbb.permission.moderatePosts", 29 30 "space.atbb.permission.banUsers", 30 31 "space.atbb.permission.pinTopics",
+779 -1
apps/appview/src/routes/__tests__/admin.test.ts
··· 2 2 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 3 3 import { Hono } from "hono"; 4 4 import type { Variables } from "../../types.js"; 5 - import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions } from "@atbb/db"; 5 + import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions, themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 6 6 import { eq } from "drizzle-orm"; 7 7 8 8 // Mock middleware at module level ··· 2442 2442 const data = await res.json() as any; 2443 2443 expect(data.limit).toBe(50); 2444 2444 expect(data.offset).toBe(0); 2445 + }); 2446 + }); 2447 + 2448 + describe("POST /api/admin/themes", () => { 2449 + it("creates theme and returns 201 with uri and cid", async () => { 2450 + const res = await app.request("/api/admin/themes", { 2451 + method: "POST", 2452 + headers: { "Content-Type": "application/json" }, 2453 + body: JSON.stringify({ 2454 + name: "Neobrutal Light", 2455 + colorScheme: "light", 2456 + tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 2457 + }), 2458 + }); 2459 + expect(res.status).toBe(201); 2460 + const body = await res.json(); 2461 + expect(body.uri).toBeDefined(); 2462 + expect(body.cid).toBeDefined(); 2463 + expect(mockPutRecord).toHaveBeenCalledOnce(); 2464 + }); 2465 + 2466 + it("includes cssOverrides and fontUrls when provided", async () => { 2467 + const res = await app.request("/api/admin/themes", { 2468 + method: "POST", 2469 + headers: { "Content-Type": "application/json" }, 2470 + body: JSON.stringify({ 2471 + name: "Custom Theme", 2472 + colorScheme: "dark", 2473 + tokens: { "color-bg": "#1a1a1a" }, 2474 + cssOverrides: ".card { border-radius: 4px; }", 2475 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"], 2476 + }), 2477 + }); 2478 + expect(res.status).toBe(201); 2479 + const call = mockPutRecord.mock.calls[0][0]; 2480 + expect(call.record.cssOverrides).toBe(".card { border-radius: 4px; }"); 2481 + expect(call.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 2482 + }); 2483 + 2484 + it("returns 400 when name is missing", async () => { 2485 + const res = await app.request("/api/admin/themes", { 2486 + method: "POST", 2487 + headers: { "Content-Type": "application/json" }, 2488 + body: JSON.stringify({ colorScheme: "light", tokens: {} }), 2489 + }); 2490 + expect(res.status).toBe(400); 2491 + const body = await res.json(); 2492 + expect(body.error).toMatch(/name/i); 2493 + }); 2494 + 2495 + it("returns 400 when name is empty string", async () => { 2496 + const res = await app.request("/api/admin/themes", { 2497 + method: "POST", 2498 + headers: { "Content-Type": "application/json" }, 2499 + body: JSON.stringify({ name: " ", colorScheme: "light", tokens: {} }), 2500 + }); 2501 + expect(res.status).toBe(400); 2502 + }); 2503 + 2504 + it("returns 400 when colorScheme is invalid", async () => { 2505 + const res = await app.request("/api/admin/themes", { 2506 + method: "POST", 2507 + headers: { "Content-Type": "application/json" }, 2508 + body: JSON.stringify({ name: "Test", colorScheme: "purple", tokens: {} }), 2509 + }); 2510 + expect(res.status).toBe(400); 2511 + const body = await res.json(); 2512 + expect(body.error).toMatch(/colorScheme/i); 2513 + }); 2514 + 2515 + it("returns 400 when colorScheme is missing", async () => { 2516 + const res = await app.request("/api/admin/themes", { 2517 + method: "POST", 2518 + headers: { "Content-Type": "application/json" }, 2519 + body: JSON.stringify({ name: "Test", tokens: {} }), 2520 + }); 2521 + expect(res.status).toBe(400); 2522 + }); 2523 + 2524 + it("returns 400 when tokens is missing", async () => { 2525 + const res = await app.request("/api/admin/themes", { 2526 + method: "POST", 2527 + headers: { "Content-Type": "application/json" }, 2528 + body: JSON.stringify({ name: "Test", colorScheme: "light" }), 2529 + }); 2530 + expect(res.status).toBe(400); 2531 + const body = await res.json(); 2532 + expect(body.error).toMatch(/tokens/i); 2533 + }); 2534 + 2535 + it("returns 400 when tokens is an array (not an object)", async () => { 2536 + const res = await app.request("/api/admin/themes", { 2537 + method: "POST", 2538 + headers: { "Content-Type": "application/json" }, 2539 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a", "b"] }), 2540 + }); 2541 + expect(res.status).toBe(400); 2542 + }); 2543 + 2544 + it("returns 400 when a token value is not a string", async () => { 2545 + const res = await app.request("/api/admin/themes", { 2546 + method: "POST", 2547 + headers: { "Content-Type": "application/json" }, 2548 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: { "color-bg": 123 } }), 2549 + }); 2550 + expect(res.status).toBe(400); 2551 + const body = await res.json(); 2552 + expect(body.error).toMatch(/tokens/i); 2553 + }); 2554 + 2555 + it("returns 400 when a fontUrl is not HTTPS", async () => { 2556 + const res = await app.request("/api/admin/themes", { 2557 + method: "POST", 2558 + headers: { "Content-Type": "application/json" }, 2559 + body: JSON.stringify({ 2560 + name: "Test", 2561 + colorScheme: "light", 2562 + tokens: {}, 2563 + fontUrls: ["http://example.com/font.css"], 2564 + }), 2565 + }); 2566 + expect(res.status).toBe(400); 2567 + const body = await res.json(); 2568 + expect(body.error).toMatch(/https/i); 2569 + }); 2570 + 2571 + it("returns 500 when ForumAgent is not configured", async () => { 2572 + ctx.forumAgent = null; 2573 + const res = await app.request("/api/admin/themes", { 2574 + method: "POST", 2575 + headers: { "Content-Type": "application/json" }, 2576 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 2577 + }); 2578 + expect(res.status).toBe(500); 2579 + const body = await res.json(); 2580 + expect(body.error).toContain("Forum agent not available"); 2581 + }); 2582 + 2583 + it("returns 503 when ForumAgent not authenticated", async () => { 2584 + const originalAgent = ctx.forumAgent; 2585 + ctx.forumAgent = { getAgent: () => null } as any; 2586 + const res = await app.request("/api/admin/themes", { 2587 + method: "POST", 2588 + headers: { "Content-Type": "application/json" }, 2589 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 2590 + }); 2591 + expect(res.status).toBe(503); 2592 + const body = await res.json(); 2593 + expect(body.error).toBe("Forum agent not authenticated. Please try again later."); 2594 + expect(mockPutRecord).not.toHaveBeenCalled(); 2595 + ctx.forumAgent = originalAgent; 2596 + }); 2597 + 2598 + it("returns 401 when not authenticated", async () => { 2599 + mockUser = null; 2600 + const res = await app.request("/api/admin/themes", { 2601 + method: "POST", 2602 + headers: { "Content-Type": "application/json" }, 2603 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 2604 + }); 2605 + expect(res.status).toBe(401); 2606 + expect(mockPutRecord).not.toHaveBeenCalled(); 2607 + }); 2608 + 2609 + it("returns 403 when user lacks manageThemes permission", async () => { 2610 + const { requirePermission } = await import("../../middleware/permissions.js"); 2611 + const mockRequirePermission = requirePermission as any; 2612 + mockRequirePermission.mockImplementation(() => async (c: any) => { 2613 + return c.json({ error: "Forbidden" }, 403); 2614 + }); 2615 + 2616 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 2617 + const res = await testApp.request("/api/admin/themes", { 2618 + method: "POST", 2619 + headers: { "Content-Type": "application/json" }, 2620 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 2621 + }); 2622 + 2623 + expect(res.status).toBe(403); 2624 + expect(mockPutRecord).not.toHaveBeenCalled(); 2625 + 2626 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 2627 + await next(); 2628 + }); 2629 + }); 2630 + 2631 + it("returns 503 when PDS write fails with a network error", async () => { 2632 + mockPutRecord.mockRejectedValueOnce(new Error("fetch failed")); 2633 + const res = await app.request("/api/admin/themes", { 2634 + method: "POST", 2635 + headers: { "Content-Type": "application/json" }, 2636 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 2637 + }); 2638 + expect(res.status).toBe(503); 2639 + }); 2640 + }); 2641 + 2642 + describe("PUT /api/admin/themes/:rkey", () => { 2643 + const TEST_RKEY = "3lblputtest1"; 2644 + const TEST_CREATED_AT = new Date("2026-01-01T00:00:00Z"); 2645 + 2646 + beforeEach(async () => { 2647 + await ctx.db.insert(themes).values({ 2648 + did: ctx.config.forumDid, 2649 + rkey: TEST_RKEY, 2650 + cid: "bafythemeput", 2651 + name: "Original Theme", 2652 + colorScheme: "light", 2653 + tokens: { "color-bg": "#ffffff", "color-text": "#000000" }, 2654 + cssOverrides: ".existing { color: red; }", 2655 + fontUrls: ["https://fonts.example.com/existing.css"], 2656 + createdAt: TEST_CREATED_AT, 2657 + indexedAt: new Date(), 2658 + }); 2659 + }); 2660 + 2661 + it("updates theme and returns 200 with uri and cid", async () => { 2662 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2663 + method: "PUT", 2664 + headers: { "Content-Type": "application/json" }, 2665 + body: JSON.stringify({ 2666 + name: "Updated Theme", 2667 + colorScheme: "dark", 2668 + tokens: { "color-bg": "#1a1a1a", "color-text": "#ffffff" }, 2669 + }), 2670 + }); 2671 + expect(res.status).toBe(200); 2672 + const body = await res.json(); 2673 + expect(body.uri).toBeDefined(); 2674 + expect(body.cid).toBeDefined(); 2675 + expect(mockPutRecord).toHaveBeenCalledOnce(); 2676 + const call = mockPutRecord.mock.calls[0][0]; 2677 + expect(call.record.name).toBe("Updated Theme"); 2678 + expect(call.record.colorScheme).toBe("dark"); 2679 + expect(call.rkey).toBe(TEST_RKEY); 2680 + }); 2681 + 2682 + it("preserves existing cssOverrides when not provided in request body", async () => { 2683 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2684 + method: "PUT", 2685 + headers: { "Content-Type": "application/json" }, 2686 + body: JSON.stringify({ 2687 + name: "Updated Theme", 2688 + colorScheme: "light", 2689 + tokens: { "color-bg": "#f0f0f0" }, 2690 + }), 2691 + }); 2692 + expect(res.status).toBe(200); 2693 + const call = mockPutRecord.mock.calls[0][0]; 2694 + expect(call.record.cssOverrides).toBe(".existing { color: red; }"); 2695 + }); 2696 + 2697 + it("preserves existing fontUrls when not provided in request body", async () => { 2698 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2699 + method: "PUT", 2700 + headers: { "Content-Type": "application/json" }, 2701 + body: JSON.stringify({ 2702 + name: "Updated Theme", 2703 + colorScheme: "light", 2704 + tokens: { "color-bg": "#f0f0f0" }, 2705 + }), 2706 + }); 2707 + expect(res.status).toBe(200); 2708 + const call = mockPutRecord.mock.calls[0][0]; 2709 + expect(call.record.fontUrls).toEqual(["https://fonts.example.com/existing.css"]); 2710 + }); 2711 + 2712 + it("preserves original createdAt in the PDS record", async () => { 2713 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2714 + method: "PUT", 2715 + headers: { "Content-Type": "application/json" }, 2716 + body: JSON.stringify({ 2717 + name: "Updated Theme", 2718 + colorScheme: "light", 2719 + tokens: { "color-bg": "#f0f0f0" }, 2720 + }), 2721 + }); 2722 + expect(res.status).toBe(200); 2723 + const call = mockPutRecord.mock.calls[0][0]; 2724 + expect(call.record.createdAt).toBe("2026-01-01T00:00:00.000Z"); 2725 + }); 2726 + 2727 + it("returns 404 for unknown rkey", async () => { 2728 + const res = await app.request("/api/admin/themes/nonexistentkey", { 2729 + method: "PUT", 2730 + headers: { "Content-Type": "application/json" }, 2731 + body: JSON.stringify({ 2732 + name: "Updated Theme", 2733 + colorScheme: "light", 2734 + tokens: { "color-bg": "#f0f0f0" }, 2735 + }), 2736 + }); 2737 + expect(res.status).toBe(404); 2738 + const body = await res.json(); 2739 + expect(body.error).toMatch(/not found/i); 2740 + }); 2741 + 2742 + it("returns 400 when name is missing", async () => { 2743 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2744 + method: "PUT", 2745 + headers: { "Content-Type": "application/json" }, 2746 + body: JSON.stringify({ colorScheme: "light", tokens: {} }), 2747 + }); 2748 + expect(res.status).toBe(400); 2749 + const body = await res.json(); 2750 + expect(body.error).toMatch(/name/i); 2751 + }); 2752 + 2753 + it("returns 400 when colorScheme is invalid", async () => { 2754 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2755 + method: "PUT", 2756 + headers: { "Content-Type": "application/json" }, 2757 + body: JSON.stringify({ name: "Test", colorScheme: "sepia", tokens: {} }), 2758 + }); 2759 + expect(res.status).toBe(400); 2760 + const body = await res.json(); 2761 + expect(body.error).toMatch(/colorScheme/i); 2762 + }); 2763 + 2764 + it("returns 400 when tokens is an array", async () => { 2765 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2766 + method: "PUT", 2767 + headers: { "Content-Type": "application/json" }, 2768 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a", "b"] }), 2769 + }); 2770 + expect(res.status).toBe(400); 2771 + const body = await res.json(); 2772 + expect(body.error).toMatch(/tokens/i); 2773 + }); 2774 + 2775 + it("returns 401 when not authenticated", async () => { 2776 + mockUser = null; 2777 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2778 + method: "PUT", 2779 + headers: { "Content-Type": "application/json" }, 2780 + body: JSON.stringify({ 2781 + name: "Updated Theme", 2782 + colorScheme: "light", 2783 + tokens: { "color-bg": "#f0f0f0" }, 2784 + }), 2785 + }); 2786 + expect(res.status).toBe(401); 2787 + expect(mockPutRecord).not.toHaveBeenCalled(); 2788 + }); 2789 + 2790 + it("returns 403 when user lacks manageThemes permission", async () => { 2791 + const { requirePermission } = await import("../../middleware/permissions.js"); 2792 + const mockRequirePermission = requirePermission as any; 2793 + mockRequirePermission.mockImplementation(() => async (c: any) => { 2794 + return c.json({ error: "Forbidden" }, 403); 2795 + }); 2796 + 2797 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 2798 + const res = await testApp.request(`/api/admin/themes/${TEST_RKEY}`, { 2799 + method: "PUT", 2800 + headers: { "Content-Type": "application/json" }, 2801 + body: JSON.stringify({ 2802 + name: "Updated Theme", 2803 + colorScheme: "light", 2804 + tokens: { "color-bg": "#f0f0f0" }, 2805 + }), 2806 + }); 2807 + 2808 + expect(res.status).toBe(403); 2809 + expect(mockPutRecord).not.toHaveBeenCalled(); 2810 + 2811 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 2812 + await next(); 2813 + }); 2814 + }); 2815 + 2816 + it("returns 503 when ForumAgent not authenticated", async () => { 2817 + const originalAgent = ctx.forumAgent; 2818 + ctx.forumAgent = { getAgent: () => null } as any; 2819 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2820 + method: "PUT", 2821 + headers: { "Content-Type": "application/json" }, 2822 + body: JSON.stringify({ name: "Updated", colorScheme: "light", tokens: {} }), 2823 + }); 2824 + expect(res.status).toBe(503); 2825 + const body = await res.json(); 2826 + expect(body.error).toBe("Forum agent not authenticated. Please try again later."); 2827 + expect(mockPutRecord).not.toHaveBeenCalled(); 2828 + ctx.forumAgent = originalAgent; 2829 + }); 2830 + 2831 + it("returns 503 when PDS write fails with a network error", async () => { 2832 + mockPutRecord.mockRejectedValueOnce(new Error("fetch failed")); 2833 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2834 + method: "PUT", 2835 + headers: { "Content-Type": "application/json" }, 2836 + body: JSON.stringify({ 2837 + name: "Updated Theme", 2838 + colorScheme: "light", 2839 + tokens: { "color-bg": "#f0f0f0" }, 2840 + }), 2841 + }); 2842 + expect(res.status).toBe(503); 2843 + }); 2844 + }); 2845 + 2846 + describe("DELETE /api/admin/themes/:rkey", () => { 2847 + const themeRkey = "3lbldeltest1"; 2848 + 2849 + beforeEach(async () => { 2850 + await ctx.db.insert(themes).values({ 2851 + did: ctx.config.forumDid, 2852 + rkey: themeRkey, 2853 + cid: "bafydeltest", 2854 + name: "Theme To Delete", 2855 + colorScheme: "light", 2856 + tokens: { "color-bg": "#ffffff" }, 2857 + createdAt: new Date(), 2858 + indexedAt: new Date(), 2859 + }); 2860 + }); 2861 + 2862 + it("deletes theme and returns 200 with success: true", async () => { 2863 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2864 + method: "DELETE", 2865 + }); 2866 + expect(res.status).toBe(200); 2867 + const body = await res.json(); 2868 + expect(body.success).toBe(true); 2869 + expect(mockDeleteRecord).toHaveBeenCalledWith({ 2870 + repo: ctx.config.forumDid, 2871 + collection: "space.atbb.forum.theme", 2872 + rkey: themeRkey, 2873 + }); 2874 + }); 2875 + 2876 + it("returns 404 for unknown rkey", async () => { 2877 + const res = await app.request("/api/admin/themes/doesnotexist", { 2878 + method: "DELETE", 2879 + }); 2880 + expect(res.status).toBe(404); 2881 + }); 2882 + 2883 + it("returns 409 when theme is the defaultLightTheme in policy", async () => { 2884 + await ctx.db.insert(themePolicies).values({ 2885 + did: ctx.config.forumDid, 2886 + rkey: "self", 2887 + cid: "bafypolicydel", 2888 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 2889 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 2890 + allowUserChoice: true, 2891 + indexedAt: new Date(), 2892 + }); 2893 + 2894 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2895 + method: "DELETE", 2896 + }); 2897 + expect(res.status).toBe(409); 2898 + const body = await res.json(); 2899 + expect(body.error).toMatch(/default/i); 2900 + }); 2901 + 2902 + it("returns 409 when theme is the defaultDarkTheme in policy", async () => { 2903 + await ctx.db.insert(themePolicies).values({ 2904 + did: ctx.config.forumDid, 2905 + rkey: "self", 2906 + cid: "bafypolicydel2", 2907 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 2908 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 2909 + allowUserChoice: true, 2910 + indexedAt: new Date(), 2911 + }); 2912 + 2913 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2914 + method: "DELETE", 2915 + }); 2916 + expect(res.status).toBe(409); 2917 + const body = await res.json(); 2918 + expect(body.error).toMatch(/default/i); 2919 + }); 2920 + 2921 + it("deletes successfully when theme exists in policy availableThemes but not as a default", async () => { 2922 + const [policy] = await ctx.db.insert(themePolicies).values({ 2923 + did: ctx.config.forumDid, 2924 + rkey: "self", 2925 + cid: "bafypolicyavail", 2926 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 2927 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 2928 + allowUserChoice: true, 2929 + indexedAt: new Date(), 2930 + }).returning(); 2931 + await ctx.db.insert(themePolicyAvailableThemes).values({ 2932 + policyId: policy.id, 2933 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 2934 + themeCid: "bafydeltest", 2935 + }); 2936 + 2937 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2938 + method: "DELETE", 2939 + }); 2940 + expect(res.status).toBe(200); 2941 + }); 2942 + 2943 + it("returns 401 when not authenticated", async () => { 2944 + mockUser = null; 2945 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2946 + method: "DELETE", 2947 + }); 2948 + expect(res.status).toBe(401); 2949 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 2950 + }); 2951 + 2952 + it("returns 403 when user lacks manageThemes permission", async () => { 2953 + const { requirePermission } = await import("../../middleware/permissions.js"); 2954 + const mockRequirePermission = requirePermission as any; 2955 + mockRequirePermission.mockImplementation(() => async (c: any) => { 2956 + return c.json({ error: "Forbidden" }, 403); 2957 + }); 2958 + 2959 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 2960 + const res = await testApp.request(`/api/admin/themes/${themeRkey}`, { 2961 + method: "DELETE", 2962 + }); 2963 + 2964 + expect(res.status).toBe(403); 2965 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 2966 + 2967 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 2968 + await next(); 2969 + }); 2970 + }); 2971 + 2972 + it("returns 503 when ForumAgent not authenticated", async () => { 2973 + const originalAgent = ctx.forumAgent; 2974 + ctx.forumAgent = { getAgent: () => null } as any; 2975 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2976 + method: "DELETE", 2977 + }); 2978 + expect(res.status).toBe(503); 2979 + const body = await res.json(); 2980 + expect(body.error).toBe("Forum agent not authenticated. Please try again later."); 2981 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 2982 + ctx.forumAgent = originalAgent; 2983 + }); 2984 + 2985 + it("returns 503 when PDS delete fails with a network error", async () => { 2986 + mockDeleteRecord.mockRejectedValueOnce(new Error("fetch failed")); 2987 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2988 + method: "DELETE", 2989 + }); 2990 + expect(res.status).toBe(503); 2991 + }); 2992 + }); 2993 + 2994 + describe("PUT /api/admin/theme-policy", () => { 2995 + const lightUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbllight1`; 2996 + const darkUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbldark11`; 2997 + 2998 + const validBody = { 2999 + availableThemes: [ 3000 + { uri: lightUri, cid: "bafylight" }, 3001 + { uri: darkUri, cid: "bafydark" }, 3002 + ], 3003 + defaultLightThemeUri: lightUri, 3004 + defaultDarkThemeUri: darkUri, 3005 + allowUserChoice: true, 3006 + }; 3007 + 3008 + it("creates policy (upsert) and returns 200 with uri and cid", async () => { 3009 + const res = await app.request("/api/admin/theme-policy", { 3010 + method: "PUT", 3011 + headers: { "Content-Type": "application/json" }, 3012 + body: JSON.stringify(validBody), 3013 + }); 3014 + expect(res.status).toBe(200); 3015 + const body = await res.json(); 3016 + expect(body.uri).toBeDefined(); 3017 + expect(body.cid).toBeDefined(); 3018 + expect(mockPutRecord).toHaveBeenCalledOnce(); 3019 + }); 3020 + 3021 + it("writes PDS record with themeRef wrapper structure", async () => { 3022 + await app.request("/api/admin/theme-policy", { 3023 + method: "PUT", 3024 + headers: { "Content-Type": "application/json" }, 3025 + body: JSON.stringify(validBody), 3026 + }); 3027 + const call = mockPutRecord.mock.calls[0][0]; 3028 + expect(call.record.$type).toBe("space.atbb.forum.themePolicy"); 3029 + expect(call.rkey).toBe("self"); 3030 + // availableThemes wrapped in { theme: { uri, cid } } 3031 + expect(call.record.availableThemes[0]).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 3032 + expect(call.record.defaultLightTheme).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 3033 + expect(call.record.defaultDarkTheme).toEqual({ theme: { uri: darkUri, cid: "bafydark" } }); 3034 + expect(call.record.allowUserChoice).toBe(true); 3035 + expect(typeof call.record.updatedAt).toBe("string"); 3036 + expect(call.collection).toBe("space.atbb.forum.themePolicy"); 3037 + expect(call.repo).toBe(ctx.config.forumDid); 3038 + }); 3039 + 3040 + it("overwrites existing policy (upsert) and returns 200 with uri and cid", async () => { 3041 + await ctx.db.insert(themePolicies).values({ 3042 + did: ctx.config.forumDid, 3043 + rkey: "self", 3044 + cid: "bafyexisting", 3045 + defaultLightThemeUri: lightUri, 3046 + defaultDarkThemeUri: darkUri, 3047 + allowUserChoice: false, 3048 + indexedAt: new Date(), 3049 + }); 3050 + 3051 + const res = await app.request("/api/admin/theme-policy", { 3052 + method: "PUT", 3053 + headers: { "Content-Type": "application/json" }, 3054 + body: JSON.stringify(validBody), 3055 + }); 3056 + expect(res.status).toBe(200); 3057 + const body = await res.json(); 3058 + expect(body.uri).toBeDefined(); 3059 + expect(body.cid).toBeDefined(); 3060 + expect(mockPutRecord).toHaveBeenCalledOnce(); 3061 + }); 3062 + 3063 + it("defaults allowUserChoice to true when not provided", async () => { 3064 + const { allowUserChoice: _, ...bodyWithout } = validBody; 3065 + await app.request("/api/admin/theme-policy", { 3066 + method: "PUT", 3067 + headers: { "Content-Type": "application/json" }, 3068 + body: JSON.stringify(bodyWithout), 3069 + }); 3070 + const call = mockPutRecord.mock.calls[0][0]; 3071 + expect(call.record.allowUserChoice).toBe(true); 3072 + }); 3073 + 3074 + it("returns 400 when availableThemes is missing", async () => { 3075 + const { availableThemes: _, ...bodyWithout } = validBody; 3076 + const res = await app.request("/api/admin/theme-policy", { 3077 + method: "PUT", 3078 + headers: { "Content-Type": "application/json" }, 3079 + body: JSON.stringify(bodyWithout), 3080 + }); 3081 + expect(res.status).toBe(400); 3082 + const body = await res.json(); 3083 + expect(body.error).toMatch(/availableThemes/i); 3084 + }); 3085 + 3086 + it("returns 400 when availableThemes is empty array", async () => { 3087 + const res = await app.request("/api/admin/theme-policy", { 3088 + method: "PUT", 3089 + headers: { "Content-Type": "application/json" }, 3090 + body: JSON.stringify({ ...validBody, availableThemes: [] }), 3091 + }); 3092 + expect(res.status).toBe(400); 3093 + const body = await res.json(); 3094 + expect(body.error).toMatch(/availableThemes/i); 3095 + }); 3096 + 3097 + it("returns 400 when availableThemes item is missing cid", async () => { 3098 + const res = await app.request("/api/admin/theme-policy", { 3099 + method: "PUT", 3100 + headers: { "Content-Type": "application/json" }, 3101 + body: JSON.stringify({ 3102 + ...validBody, 3103 + availableThemes: [{ uri: lightUri }], // missing cid 3104 + defaultLightThemeUri: lightUri, 3105 + defaultDarkThemeUri: lightUri, 3106 + }), 3107 + }); 3108 + expect(res.status).toBe(400); 3109 + const body = await res.json(); 3110 + expect(body.error).toMatch(/uri.*cid|cid.*uri|uri and cid/i); 3111 + }); 3112 + 3113 + it("returns 400 when defaultLightThemeUri is not in availableThemes", async () => { 3114 + const res = await app.request("/api/admin/theme-policy", { 3115 + method: "PUT", 3116 + headers: { "Content-Type": "application/json" }, 3117 + body: JSON.stringify({ 3118 + ...validBody, 3119 + defaultLightThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist", 3120 + }), 3121 + }); 3122 + expect(res.status).toBe(400); 3123 + const body = await res.json(); 3124 + expect(body.error).toMatch(/defaultLightThemeUri/i); 3125 + }); 3126 + 3127 + it("returns 400 when defaultDarkThemeUri is not in availableThemes", async () => { 3128 + const res = await app.request("/api/admin/theme-policy", { 3129 + method: "PUT", 3130 + headers: { "Content-Type": "application/json" }, 3131 + body: JSON.stringify({ 3132 + ...validBody, 3133 + defaultDarkThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist", 3134 + }), 3135 + }); 3136 + expect(res.status).toBe(400); 3137 + const body = await res.json(); 3138 + expect(body.error).toMatch(/defaultDarkThemeUri/i); 3139 + }); 3140 + 3141 + it("returns 400 when defaultLightThemeUri is missing", async () => { 3142 + const { defaultLightThemeUri: _, ...bodyWithout } = validBody; 3143 + const res = await app.request("/api/admin/theme-policy", { 3144 + method: "PUT", 3145 + headers: { "Content-Type": "application/json" }, 3146 + body: JSON.stringify(bodyWithout), 3147 + }); 3148 + expect(res.status).toBe(400); 3149 + const body = await res.json(); 3150 + expect(body.error).toMatch(/defaultLightThemeUri/i); 3151 + }); 3152 + 3153 + it("returns 400 when defaultDarkThemeUri is missing", async () => { 3154 + const { defaultDarkThemeUri: _, ...bodyWithout } = validBody; 3155 + const res = await app.request("/api/admin/theme-policy", { 3156 + method: "PUT", 3157 + headers: { "Content-Type": "application/json" }, 3158 + body: JSON.stringify(bodyWithout), 3159 + }); 3160 + expect(res.status).toBe(400); 3161 + const body = await res.json(); 3162 + expect(body.error).toMatch(/defaultDarkThemeUri/i); 3163 + }); 3164 + 3165 + it("returns 401 when not authenticated", async () => { 3166 + mockUser = null; 3167 + const res = await app.request("/api/admin/theme-policy", { 3168 + method: "PUT", 3169 + headers: { "Content-Type": "application/json" }, 3170 + body: JSON.stringify(validBody), 3171 + }); 3172 + expect(res.status).toBe(401); 3173 + expect(mockPutRecord).not.toHaveBeenCalled(); 3174 + }); 3175 + 3176 + it("returns 403 when user lacks manageThemes permission", async () => { 3177 + const { requirePermission } = await import("../../middleware/permissions.js"); 3178 + const mockRequirePermission = requirePermission as any; 3179 + mockRequirePermission.mockImplementation(() => async (c: any) => { 3180 + return c.json({ error: "Forbidden" }, 403); 3181 + }); 3182 + 3183 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 3184 + const res = await testApp.request("/api/admin/theme-policy", { 3185 + method: "PUT", 3186 + headers: { "Content-Type": "application/json" }, 3187 + body: JSON.stringify(validBody), 3188 + }); 3189 + 3190 + expect(res.status).toBe(403); 3191 + expect(mockPutRecord).not.toHaveBeenCalled(); 3192 + 3193 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 3194 + await next(); 3195 + }); 3196 + }); 3197 + 3198 + it("returns 500 when ForumAgent is not configured", async () => { 3199 + ctx.forumAgent = null; 3200 + const res = await app.request("/api/admin/theme-policy", { 3201 + method: "PUT", 3202 + headers: { "Content-Type": "application/json" }, 3203 + body: JSON.stringify(validBody), 3204 + }); 3205 + expect(res.status).toBe(500); 3206 + const body = await res.json(); 3207 + expect(body.error).toContain("Forum agent not available"); 3208 + }); 3209 + 3210 + it("returns 503 when ForumAgent not authenticated", async () => { 3211 + const originalAgent = ctx.forumAgent; 3212 + ctx.forumAgent = { getAgent: () => null } as any; 3213 + const res = await app.request("/api/admin/theme-policy", { 3214 + method: "PUT", 3215 + headers: { "Content-Type": "application/json" }, 3216 + body: JSON.stringify(validBody), 3217 + }); 3218 + expect(res.status).toBe(503); 3219 + const body = await res.json(); 3220 + expect(body.error).toBe("Forum agent not authenticated. Please try again later."); 3221 + expect(mockPutRecord).not.toHaveBeenCalled(); 3222 + ctx.forumAgent = originalAgent; 2445 3223 }); 2446 3224 }); 2447 3225
+350 -2
apps/appview/src/routes/admin.ts
··· 3 3 import type { Variables } from "../types.js"; 4 4 import { requireAuth } from "../middleware/auth.js"; 5 5 import { requirePermission, requireAnyPermission, getUserRole } from "../middleware/permissions.js"; 6 - import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions } from "@atbb/db"; 7 - import { eq, and, sql, asc, desc, count } from "drizzle-orm"; 6 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions, themes, themePolicies } from "@atbb/db"; 7 + import { eq, and, sql, asc, desc, count, or } from "drizzle-orm"; 8 8 import { alias } from "drizzle-orm/pg-core"; 9 9 import { isProgrammingError } from "../lib/errors.js"; 10 10 import { BackfillStatus } from "../lib/backfill-manager.js"; ··· 976 976 operation: "DELETE /api/admin/boards/:id", 977 977 logger: ctx.logger, 978 978 id: idParam, 979 + }); 980 + } 981 + } 982 + ); 983 + 984 + /** 985 + * POST /api/admin/themes 986 + * 987 + * Create a new theme record on Forum DID's PDS. 988 + * Writes space.atbb.forum.theme with a fresh TID rkey. 989 + * The firehose indexer creates the DB row asynchronously. 990 + */ 991 + app.post( 992 + "/themes", 993 + requireAuth(ctx), 994 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 995 + async (c) => { 996 + const { body, error: parseError } = await safeParseJsonBody(c); 997 + if (parseError) return parseError; 998 + 999 + const { name, colorScheme, tokens, cssOverrides, fontUrls } = body; 1000 + 1001 + if (typeof name !== "string" || name.trim().length === 0) { 1002 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 1003 + } 1004 + if (typeof colorScheme !== "string" || (colorScheme !== "light" && colorScheme !== "dark")) { 1005 + return c.json({ error: 'colorScheme is required and must be "light" or "dark"' }, 400); 1006 + } 1007 + if (tokens === null || tokens === undefined || typeof tokens !== "object" || Array.isArray(tokens)) { 1008 + return c.json({ error: "tokens is required and must be a plain object" }, 400); 1009 + } 1010 + for (const [key, val] of Object.entries(tokens as Record<string, unknown>)) { 1011 + if (typeof val !== "string") { 1012 + return c.json({ error: `tokens["${key}"] must be a string` }, 400); 1013 + } 1014 + } 1015 + if (cssOverrides !== undefined && typeof cssOverrides !== "string") { 1016 + return c.json({ error: "cssOverrides must be a string" }, 400); 1017 + } 1018 + if (fontUrls !== undefined) { 1019 + if (!Array.isArray(fontUrls)) { 1020 + return c.json({ error: "fontUrls must be an array of strings" }, 400); 1021 + } 1022 + for (const url of fontUrls as unknown[]) { 1023 + if (typeof url !== "string" || !url.startsWith("https://")) { 1024 + return c.json({ error: "fontUrls must contain only HTTPS URLs" }, 400); 1025 + } 1026 + } 1027 + } 1028 + 1029 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/themes"); 1030 + if (agentError) return agentError; 1031 + 1032 + const rkey = TID.nextStr(); 1033 + const now = new Date().toISOString(); 1034 + 1035 + try { 1036 + const result = await agent.com.atproto.repo.putRecord({ 1037 + repo: ctx.config.forumDid, 1038 + collection: "space.atbb.forum.theme", 1039 + rkey, 1040 + record: { 1041 + $type: "space.atbb.forum.theme", 1042 + name: name.trim(), 1043 + colorScheme, 1044 + tokens, 1045 + ...(typeof cssOverrides === "string" && { cssOverrides }), 1046 + ...(Array.isArray(fontUrls) && { fontUrls }), 1047 + createdAt: now, 1048 + }, 1049 + }); 1050 + 1051 + return c.json({ uri: result.data.uri, cid: result.data.cid }, 201); 1052 + } catch (error) { 1053 + return handleRouteError(c, error, "Failed to create theme", { 1054 + operation: "POST /api/admin/themes", 1055 + logger: ctx.logger, 1056 + }); 1057 + } 1058 + } 1059 + ); 1060 + 1061 + /** 1062 + * PUT /api/admin/themes/:rkey 1063 + * 1064 + * Update an existing theme. Fetches the existing row from DB to preserve 1065 + * createdAt and fall back optional fields not in the request body. 1066 + * The firehose indexer updates the DB row asynchronously. 1067 + */ 1068 + app.put( 1069 + "/themes/:rkey", 1070 + requireAuth(ctx), 1071 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 1072 + async (c) => { 1073 + const themeRkey = c.req.param("rkey").trim(); 1074 + 1075 + const { body, error: parseError } = await safeParseJsonBody(c); 1076 + if (parseError) return parseError; 1077 + 1078 + const { name, colorScheme, tokens, cssOverrides, fontUrls } = body; 1079 + 1080 + if (typeof name !== "string" || name.trim().length === 0) { 1081 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 1082 + } 1083 + if (typeof colorScheme !== "string" || (colorScheme !== "light" && colorScheme !== "dark")) { 1084 + return c.json({ error: 'colorScheme is required and must be "light" or "dark"' }, 400); 1085 + } 1086 + if (tokens === null || tokens === undefined || typeof tokens !== "object" || Array.isArray(tokens)) { 1087 + return c.json({ error: "tokens is required and must be a plain object" }, 400); 1088 + } 1089 + for (const [key, val] of Object.entries(tokens as Record<string, unknown>)) { 1090 + if (typeof val !== "string") { 1091 + return c.json({ error: `tokens["${key}"] must be a string` }, 400); 1092 + } 1093 + } 1094 + if (cssOverrides !== undefined && typeof cssOverrides !== "string") { 1095 + return c.json({ error: "cssOverrides must be a string" }, 400); 1096 + } 1097 + if (fontUrls !== undefined) { 1098 + if (!Array.isArray(fontUrls)) { 1099 + return c.json({ error: "fontUrls must be an array of strings" }, 400); 1100 + } 1101 + for (const url of fontUrls as unknown[]) { 1102 + if (typeof url !== "string" || !url.startsWith("https://")) { 1103 + return c.json({ error: "fontUrls must contain only HTTPS URLs" }, 400); 1104 + } 1105 + } 1106 + } 1107 + 1108 + let theme: typeof themes.$inferSelect; 1109 + try { 1110 + const [row] = await ctx.db 1111 + .select() 1112 + .from(themes) 1113 + .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, themeRkey))) 1114 + .limit(1); 1115 + 1116 + if (!row) { 1117 + return c.json({ error: "Theme not found" }, 404); 1118 + } 1119 + theme = row; 1120 + } catch (error) { 1121 + return handleRouteError(c, error, "Failed to look up theme", { 1122 + operation: "PUT /api/admin/themes/:rkey", 1123 + logger: ctx.logger, 1124 + themeRkey, 1125 + }); 1126 + } 1127 + 1128 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/themes/:rkey"); 1129 + if (agentError) return agentError; 1130 + 1131 + // putRecord is a full replacement — fall back to existing values for 1132 + // optional fields not provided in the request body, to avoid data loss. 1133 + const resolvedCssOverrides = typeof cssOverrides === "string" ? cssOverrides : theme.cssOverrides; 1134 + const resolvedFontUrls = Array.isArray(fontUrls) ? fontUrls : (theme.fontUrls as string[] | null); 1135 + 1136 + try { 1137 + const result = await agent.com.atproto.repo.putRecord({ 1138 + repo: ctx.config.forumDid, 1139 + collection: "space.atbb.forum.theme", 1140 + rkey: theme.rkey, 1141 + record: { 1142 + $type: "space.atbb.forum.theme", 1143 + name: name.trim(), 1144 + colorScheme, 1145 + tokens, 1146 + ...(resolvedCssOverrides != null && { cssOverrides: resolvedCssOverrides }), 1147 + ...(resolvedFontUrls != null && { fontUrls: resolvedFontUrls }), 1148 + createdAt: theme.createdAt.toISOString(), 1149 + updatedAt: new Date().toISOString(), 1150 + }, 1151 + }); 1152 + 1153 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 1154 + } catch (error) { 1155 + return handleRouteError(c, error, "Failed to update theme", { 1156 + operation: "PUT /api/admin/themes/:rkey", 1157 + logger: ctx.logger, 1158 + themeRkey, 1159 + }); 1160 + } 1161 + } 1162 + ); 1163 + 1164 + /** 1165 + * DELETE /api/admin/themes/:rkey 1166 + * 1167 + * Delete a theme. Pre-flight: refuses with 409 if the theme is set as 1168 + * defaultLightTheme or defaultDarkTheme in the theme policy. 1169 + * The firehose indexer removes the DB row asynchronously. 1170 + */ 1171 + app.delete( 1172 + "/themes/:rkey", 1173 + requireAuth(ctx), 1174 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 1175 + async (c) => { 1176 + const themeRkey = c.req.param("rkey").trim(); 1177 + 1178 + let theme: typeof themes.$inferSelect; 1179 + try { 1180 + const [row] = await ctx.db 1181 + .select() 1182 + .from(themes) 1183 + .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, themeRkey))) 1184 + .limit(1); 1185 + 1186 + if (!row) { 1187 + return c.json({ error: "Theme not found" }, 404); 1188 + } 1189 + theme = row; 1190 + } catch (error) { 1191 + return handleRouteError(c, error, "Failed to look up theme", { 1192 + operation: "DELETE /api/admin/themes/:rkey", 1193 + logger: ctx.logger, 1194 + themeRkey, 1195 + }); 1196 + } 1197 + 1198 + // Pre-flight conflict check: refuse if this theme is a policy default 1199 + const themeUri = `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`; 1200 + try { 1201 + const [conflictingPolicy] = await ctx.db 1202 + .select({ id: themePolicies.id }) 1203 + .from(themePolicies) 1204 + .where( 1205 + and( 1206 + eq(themePolicies.did, ctx.config.forumDid), 1207 + or( 1208 + eq(themePolicies.defaultLightThemeUri, themeUri), 1209 + eq(themePolicies.defaultDarkThemeUri, themeUri) 1210 + ) 1211 + ) 1212 + ) 1213 + .limit(1); 1214 + 1215 + if (conflictingPolicy) { 1216 + return c.json( 1217 + { error: "Cannot delete a theme that is currently set as a default. Update the theme policy first." }, 1218 + 409 1219 + ); 1220 + } 1221 + } catch (error) { 1222 + return handleRouteError(c, error, "Failed to check theme policy", { 1223 + operation: "DELETE /api/admin/themes/:rkey", 1224 + logger: ctx.logger, 1225 + themeRkey, 1226 + }); 1227 + } 1228 + 1229 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/themes/:rkey"); 1230 + if (agentError) return agentError; 1231 + 1232 + try { 1233 + await agent.com.atproto.repo.deleteRecord({ 1234 + repo: ctx.config.forumDid, 1235 + collection: "space.atbb.forum.theme", 1236 + rkey: theme.rkey, 1237 + }); 1238 + 1239 + return c.json({ success: true }); 1240 + } catch (error) { 1241 + return handleRouteError(c, error, "Failed to delete theme", { 1242 + operation: "DELETE /api/admin/themes/:rkey", 1243 + logger: ctx.logger, 1244 + themeRkey, 1245 + }); 1246 + } 1247 + } 1248 + ); 1249 + 1250 + /** 1251 + * PUT /api/admin/theme-policy 1252 + * 1253 + * Create or update the themePolicy singleton (rkey: "self") on Forum DID's PDS. 1254 + * Upsert semantics: works whether or not a policy record exists yet. 1255 + * The firehose indexer creates/updates the DB row asynchronously. 1256 + */ 1257 + app.put( 1258 + "/theme-policy", 1259 + requireAuth(ctx), 1260 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 1261 + async (c) => { 1262 + const { body, error: parseError } = await safeParseJsonBody(c); 1263 + if (parseError) return parseError; 1264 + 1265 + const { availableThemes, defaultLightThemeUri, defaultDarkThemeUri, allowUserChoice } = body; 1266 + 1267 + if (!Array.isArray(availableThemes) || availableThemes.length === 0) { 1268 + return c.json({ error: "availableThemes is required and must be a non-empty array" }, 400); 1269 + } 1270 + for (const t of availableThemes as unknown[]) { 1271 + if ( 1272 + typeof t !== "object" || 1273 + t === null || 1274 + typeof (t as Record<string, unknown>).uri !== "string" || 1275 + typeof (t as Record<string, unknown>).cid !== "string" 1276 + ) { 1277 + return c.json({ error: "Each availableThemes entry must have uri and cid string fields" }, 400); 1278 + } 1279 + } 1280 + 1281 + if (typeof defaultLightThemeUri !== "string" || !defaultLightThemeUri.startsWith("at://")) { 1282 + return c.json({ error: "defaultLightThemeUri is required and must be an AT-URI" }, 400); 1283 + } 1284 + if (typeof defaultDarkThemeUri !== "string" || !defaultDarkThemeUri.startsWith("at://")) { 1285 + return c.json({ error: "defaultDarkThemeUri is required and must be an AT-URI" }, 400); 1286 + } 1287 + 1288 + const availableUris = (availableThemes as Array<{ uri: string; cid: string }>).map((t) => t.uri); 1289 + if (!availableUris.includes(defaultLightThemeUri)) { 1290 + return c.json({ error: "defaultLightThemeUri must be present in availableThemes" }, 400); 1291 + } 1292 + if (!availableUris.includes(defaultDarkThemeUri)) { 1293 + return c.json({ error: "defaultDarkThemeUri must be present in availableThemes" }, 400); 1294 + } 1295 + 1296 + const resolvedAllowUserChoice = typeof allowUserChoice === "boolean" ? allowUserChoice : true; 1297 + 1298 + const typedAvailableThemes = availableThemes as Array<{ uri: string; cid: string }>; 1299 + const lightTheme = typedAvailableThemes.find((t) => t.uri === defaultLightThemeUri)!; 1300 + const darkTheme = typedAvailableThemes.find((t) => t.uri === defaultDarkThemeUri)!; 1301 + 1302 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/theme-policy"); 1303 + if (agentError) return agentError; 1304 + 1305 + try { 1306 + const result = await agent.com.atproto.repo.putRecord({ 1307 + repo: ctx.config.forumDid, 1308 + collection: "space.atbb.forum.themePolicy", 1309 + rkey: "self", 1310 + record: { 1311 + $type: "space.atbb.forum.themePolicy", 1312 + availableThemes: typedAvailableThemes.map((t) => ({ 1313 + theme: { uri: t.uri, cid: t.cid }, 1314 + })), 1315 + defaultLightTheme: { theme: { uri: lightTheme.uri, cid: lightTheme.cid } }, 1316 + defaultDarkTheme: { theme: { uri: darkTheme.uri, cid: darkTheme.cid } }, 1317 + allowUserChoice: resolvedAllowUserChoice, 1318 + updatedAt: new Date().toISOString(), 1319 + }, 1320 + }); 1321 + 1322 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 1323 + } catch (error) { 1324 + return handleRouteError(c, error, "Failed to update theme policy", { 1325 + operation: "PUT /api/admin/theme-policy", 1326 + logger: ctx.logger, 979 1327 }); 980 1328 } 981 1329 }
+55
bruno/AppView API/Admin Themes/Create Theme.bru
··· 1 + meta { 2 + name: Create Theme 3 + type: http 4 + seq: 1 5 + } 6 + 7 + post { 8 + url: {{appview_url}}/api/admin/themes 9 + } 10 + 11 + body:json { 12 + { 13 + "name": "Neobrutal Light", 14 + "colorScheme": "light", 15 + "tokens": { 16 + "color-bg": "#f5f0e8", 17 + "color-text": "#1a1a1a", 18 + "color-primary": "#ff5c00" 19 + }, 20 + "fontUrls": ["https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700"] 21 + } 22 + } 23 + 24 + assert { 25 + res.status: eq 201 26 + res.body.uri: isDefined 27 + res.body.cid: isDefined 28 + } 29 + 30 + docs { 31 + Create a new theme record on the Forum DID's PDS. 32 + The firehose indexer creates the DB row asynchronously. 33 + 34 + **Requires:** space.atbb.permission.manageThemes 35 + 36 + Body: 37 + - name (required): Theme display name, non-empty 38 + - colorScheme (required): "light" or "dark" 39 + - tokens (required): Plain object of CSS design token key-value pairs. Values must be strings. 40 + - cssOverrides (optional): Raw CSS string for structural overrides (not rendered until ATB-62 sanitization ships) 41 + - fontUrls (optional): Array of HTTPS URLs for font stylesheets 42 + 43 + Returns (201): 44 + { 45 + "uri": "at://did:plc:.../space.atbb.forum.theme/abc123", 46 + "cid": "bafyrei..." 47 + } 48 + 49 + Error codes: 50 + - 400: Missing name/colorScheme/tokens, invalid colorScheme, non-HTTPS fontUrl, token value not a string, malformed JSON 51 + - 401: Not authenticated 52 + - 403: Missing manageThemes permission 53 + - 500: ForumAgent not configured (server configuration issue) 54 + - 503: ForumAgent not authenticated or PDS network error 55 + }
+37
bruno/AppView API/Admin Themes/Delete Theme.bru
··· 1 + meta { 2 + name: Delete Theme 3 + type: http 4 + seq: 3 5 + } 6 + 7 + delete { 8 + url: {{appview_url}}/api/admin/themes/{{theme_rkey}} 9 + } 10 + 11 + assert { 12 + res.status: eq 200 13 + res.body.success: eq true 14 + } 15 + 16 + docs { 17 + Delete a theme record. Fails with 409 if the theme is currently set as 18 + the defaultLightTheme or defaultDarkTheme in the theme policy. 19 + 20 + **Requires:** space.atbb.permission.manageThemes 21 + 22 + Path params: 23 + - rkey: Theme record key (TID) 24 + 25 + Returns (200): 26 + { 27 + "success": true 28 + } 29 + 30 + Error codes: 31 + - 401: Not authenticated 32 + - 403: Missing manageThemes permission 33 + - 404: Theme not found 34 + - 409: Theme is the current defaultLightTheme or defaultDarkTheme — update theme policy first 35 + - 500: ForumAgent not configured (server configuration issue) 36 + - 503: ForumAgent not authenticated or PDS network error 37 + }
+57
bruno/AppView API/Admin Themes/Update Theme Policy.bru
··· 1 + meta { 2 + name: Update Theme Policy 3 + type: http 4 + seq: 4 5 + } 6 + 7 + put { 8 + url: {{appview_url}}/api/admin/theme-policy 9 + } 10 + 11 + body:json { 12 + { 13 + "availableThemes": [ 14 + { "uri": "at://did:plc:example/space.atbb.forum.theme/3lbllight1", "cid": "bafylight" }, 15 + { "uri": "at://did:plc:example/space.atbb.forum.theme/3lbldark11", "cid": "bafydark" } 16 + ], 17 + "defaultLightThemeUri": "at://did:plc:example/space.atbb.forum.theme/3lbllight1", 18 + "defaultDarkThemeUri": "at://did:plc:example/space.atbb.forum.theme/3lbldark11", 19 + "allowUserChoice": true 20 + } 21 + } 22 + 23 + assert { 24 + res.status: eq 200 25 + res.body.uri: isDefined 26 + res.body.cid: isDefined 27 + } 28 + 29 + docs { 30 + Create or update the themePolicy singleton on the Forum DID's PDS. 31 + Uses upsert semantics: works whether or not a policy record exists yet. 32 + 33 + **Requires:** space.atbb.permission.manageThemes 34 + 35 + Body: 36 + - availableThemes (required): Non-empty array of { uri, cid } theme references. 37 + Both defaultLightThemeUri and defaultDarkThemeUri must be present in this list. 38 + - defaultLightThemeUri (required): AT-URI of the default light-mode theme. 39 + Must be in availableThemes. 40 + - defaultDarkThemeUri (required): AT-URI of the default dark-mode theme. 41 + Must be in availableThemes. 42 + - allowUserChoice (optional, default true): Whether users can pick their own theme. 43 + 44 + Returns (200): 45 + { 46 + "uri": "at://did:plc:.../space.atbb.forum.themePolicy/self", 47 + "cid": "bafyrei..." 48 + } 49 + 50 + Error codes: 51 + - 400: Missing/empty availableThemes, missing defaultLightThemeUri/defaultDarkThemeUri, 52 + default URI not in availableThemes list, malformed JSON 53 + - 401: Not authenticated 54 + - 403: Missing manageThemes permission 55 + - 500: ForumAgent not configured (server configuration issue) 56 + - 503: ForumAgent not authenticated or PDS network error 57 + }
+54
bruno/AppView API/Admin Themes/Update Theme.bru
··· 1 + meta { 2 + name: Update Theme 3 + type: http 4 + seq: 2 5 + } 6 + 7 + put { 8 + url: {{appview_url}}/api/admin/themes/{{theme_rkey}} 9 + } 10 + 11 + body:json { 12 + { 13 + "name": "Neobrutal Light (Updated)", 14 + "colorScheme": "light", 15 + "tokens": { 16 + "color-bg": "#f5f0e8", 17 + "color-text": "#1a1a1a", 18 + "color-primary": "#ff5c00" 19 + } 20 + } 21 + } 22 + 23 + assert { 24 + res.status: eq 200 25 + res.body.uri: isDefined 26 + res.body.cid: isDefined 27 + } 28 + 29 + docs { 30 + Update an existing theme record. Full replacement of the PDS record. 31 + Optional fields (cssOverrides, fontUrls) fall back to their existing values 32 + when omitted from the request body. 33 + 34 + **Requires:** space.atbb.permission.manageThemes 35 + 36 + Path params: 37 + - rkey: Theme record key (TID) 38 + 39 + Body: same as Create Theme (all fields). 40 + 41 + Returns (200): 42 + { 43 + "uri": "at://did:plc:.../space.atbb.forum.theme/abc123", 44 + "cid": "bafyrei..." 45 + } 46 + 47 + Error codes: 48 + - 400: Invalid input (same as Create Theme) 49 + - 401: Not authenticated 50 + - 403: Missing manageThemes permission 51 + - 404: Theme not found 52 + - 500: ForumAgent not configured (server configuration issue) 53 + - 503: ForumAgent not authenticated or PDS network error 54 + }
+1417
docs/plans/complete/2026-03-02-atb-57-theme-write-api.md
··· 1 + # ATB-57: Theme Write API Endpoints — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add admin write endpoints for creating, updating, and deleting themes, and managing the theme policy singleton. 6 + 7 + **Architecture:** All four endpoints live in `apps/appview/src/routes/admin.ts` (same file as category/board write endpoints), gated by a new `space.atbb.permission.manageThemes` permission. They follow the established PDS-first pattern: validate → get ForumAgent → `putRecord`/`deleteRecord` on Forum DID's PDS → return `{ uri, cid }`. The firehose indexer handles DB rows asynchronously. 8 + 9 + **Tech Stack:** Hono, Drizzle ORM (postgres.js), AT Protocol (`com.atproto.repo.putRecord`), `@atproto/common-web` TID generator, Vitest 10 + 11 + --- 12 + 13 + ## Context & Patterns to Know 14 + 15 + **The PDS-first write pattern** (established by categories/boards in `admin.ts`): 16 + 1. Parse + validate request body (`safeParseJsonBody`) 17 + 2. For PUT/DELETE: look up existing DB row first — 404 if missing 18 + 3. `getForumAgentOrError` — returns 503 if ForumAgent not configured 19 + 4. Call `agent.com.atproto.repo.putRecord` / `deleteRecord` 20 + 5. Return `{ uri, cid }` from result 21 + 22 + **Test scaffolding** (from `admin.test.ts`): Auth and permissions middleware are **mocked at module level** — `requireAuth` always passes the `mockUser` object, `requirePermission` always calls `next()`. Tests set `ctx.forumAgent` to a mock with `mockPutRecord` / `mockDeleteRecord`. Tests use `describe.sequential` because they share a single `TestContext`. 23 + 24 + **Running tests:** 25 + ```bash 26 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview test 27 + # or for a single file: 28 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 29 + ``` 30 + 31 + Replace `/path/to/` with the absolute path to the repo root (check with `pwd`). 32 + 33 + **Imports needed in `admin.ts`** — you'll need to add: 34 + - `themes, themePolicies` to the `@atbb/db` import 35 + - `or` to the `drizzle-orm` import (it's not there yet) 36 + 37 + --- 38 + 39 + ## Task 1: Add `manageThemes` permission to seed-roles 40 + 41 + **Files:** 42 + - Modify: `apps/appview/src/lib/seed-roles.ts` 43 + 44 + No test needed (it's runtime seed data, not business logic). The Admin role needs `space.atbb.permission.manageThemes` added. 45 + 46 + **Step 1: Add permission to Admin role** 47 + 48 + In `seed-roles.ts`, find the `"Admin"` entry in `DEFAULT_ROLES` and add `"space.atbb.permission.manageThemes"` to its `permissions` array: 49 + 50 + ```typescript 51 + { 52 + name: "Admin", 53 + description: "Can manage forum structure and users", 54 + permissions: [ 55 + "space.atbb.permission.manageCategories", 56 + "space.atbb.permission.manageRoles", 57 + "space.atbb.permission.manageMembers", 58 + "space.atbb.permission.manageThemes", // ← add this line 59 + "space.atbb.permission.moderatePosts", 60 + "space.atbb.permission.banUsers", 61 + "space.atbb.permission.pinTopics", 62 + "space.atbb.permission.lockTopics", 63 + "space.atbb.permission.createTopics", 64 + "space.atbb.permission.createPosts", 65 + ], 66 + priority: 10, 67 + critical: true, 68 + }, 69 + ``` 70 + 71 + **Step 2: Verify existing tests still pass** 72 + 73 + ```bash 74 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run 75 + ``` 76 + 77 + Expected: all existing tests pass. 78 + 79 + **Step 3: Commit** 80 + 81 + ```bash 82 + git add apps/appview/src/lib/seed-roles.ts 83 + git commit -m "feat(appview): add manageThemes permission to Admin role (ATB-57)" 84 + ``` 85 + 86 + --- 87 + 88 + ## Task 2: Write failing tests for `POST /api/admin/themes` 89 + 90 + **Files:** 91 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 92 + 93 + **Step 1: Add import for `themes` table** 94 + 95 + At the top of `admin.test.ts`, find the `@atbb/db` import and add `themes`: 96 + 97 + ```typescript 98 + import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions, themes } from "@atbb/db"; 99 + ``` 100 + 101 + **Step 2: Add the test describe block** 102 + 103 + At the bottom of the file (inside `describe.sequential("Admin Routes", ...)`, before the closing brace), add: 104 + 105 + ```typescript 106 + describe("POST /api/admin/themes", () => { 107 + it("creates theme and returns 201 with uri and cid", async () => { 108 + const res = await app.request("/api/admin/themes", { 109 + method: "POST", 110 + headers: { "Content-Type": "application/json" }, 111 + body: JSON.stringify({ 112 + name: "Neobrutal Light", 113 + colorScheme: "light", 114 + tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 115 + }), 116 + }); 117 + expect(res.status).toBe(201); 118 + const body = await res.json(); 119 + expect(body.uri).toBeDefined(); 120 + expect(body.cid).toBeDefined(); 121 + expect(mockPutRecord).toHaveBeenCalledOnce(); 122 + }); 123 + 124 + it("includes cssOverrides and fontUrls when provided", async () => { 125 + const res = await app.request("/api/admin/themes", { 126 + method: "POST", 127 + headers: { "Content-Type": "application/json" }, 128 + body: JSON.stringify({ 129 + name: "Custom Theme", 130 + colorScheme: "dark", 131 + tokens: { "color-bg": "#1a1a1a" }, 132 + cssOverrides: ".card { border-radius: 4px; }", 133 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"], 134 + }), 135 + }); 136 + expect(res.status).toBe(201); 137 + const call = mockPutRecord.mock.calls[0][0]; 138 + expect(call.record.cssOverrides).toBe(".card { border-radius: 4px; }"); 139 + expect(call.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 140 + }); 141 + 142 + it("returns 400 when name is missing", async () => { 143 + const res = await app.request("/api/admin/themes", { 144 + method: "POST", 145 + headers: { "Content-Type": "application/json" }, 146 + body: JSON.stringify({ colorScheme: "light", tokens: {} }), 147 + }); 148 + expect(res.status).toBe(400); 149 + const body = await res.json(); 150 + expect(body.error).toMatch(/name/i); 151 + }); 152 + 153 + it("returns 400 when name is empty string", async () => { 154 + const res = await app.request("/api/admin/themes", { 155 + method: "POST", 156 + headers: { "Content-Type": "application/json" }, 157 + body: JSON.stringify({ name: " ", colorScheme: "light", tokens: {} }), 158 + }); 159 + expect(res.status).toBe(400); 160 + }); 161 + 162 + it("returns 400 when colorScheme is invalid", async () => { 163 + const res = await app.request("/api/admin/themes", { 164 + method: "POST", 165 + headers: { "Content-Type": "application/json" }, 166 + body: JSON.stringify({ name: "Test", colorScheme: "purple", tokens: {} }), 167 + }); 168 + expect(res.status).toBe(400); 169 + const body = await res.json(); 170 + expect(body.error).toMatch(/colorScheme/i); 171 + }); 172 + 173 + it("returns 400 when colorScheme is missing", async () => { 174 + const res = await app.request("/api/admin/themes", { 175 + method: "POST", 176 + headers: { "Content-Type": "application/json" }, 177 + body: JSON.stringify({ name: "Test", tokens: {} }), 178 + }); 179 + expect(res.status).toBe(400); 180 + }); 181 + 182 + it("returns 400 when tokens is missing", async () => { 183 + const res = await app.request("/api/admin/themes", { 184 + method: "POST", 185 + headers: { "Content-Type": "application/json" }, 186 + body: JSON.stringify({ name: "Test", colorScheme: "light" }), 187 + }); 188 + expect(res.status).toBe(400); 189 + const body = await res.json(); 190 + expect(body.error).toMatch(/tokens/i); 191 + }); 192 + 193 + it("returns 400 when tokens is an array (not an object)", async () => { 194 + const res = await app.request("/api/admin/themes", { 195 + method: "POST", 196 + headers: { "Content-Type": "application/json" }, 197 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a", "b"] }), 198 + }); 199 + expect(res.status).toBe(400); 200 + }); 201 + 202 + it("returns 400 when a token value is not a string", async () => { 203 + const res = await app.request("/api/admin/themes", { 204 + method: "POST", 205 + headers: { "Content-Type": "application/json" }, 206 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: { "color-bg": 123 } }), 207 + }); 208 + expect(res.status).toBe(400); 209 + const body = await res.json(); 210 + expect(body.error).toMatch(/tokens/i); 211 + }); 212 + 213 + it("returns 400 when a fontUrl is not HTTPS", async () => { 214 + const res = await app.request("/api/admin/themes", { 215 + method: "POST", 216 + headers: { "Content-Type": "application/json" }, 217 + body: JSON.stringify({ 218 + name: "Test", 219 + colorScheme: "light", 220 + tokens: {}, 221 + fontUrls: ["http://example.com/font.css"], 222 + }), 223 + }); 224 + expect(res.status).toBe(400); 225 + const body = await res.json(); 226 + expect(body.error).toMatch(/https/i); 227 + }); 228 + 229 + it("returns 503 when ForumAgent is not configured", async () => { 230 + ctx.forumAgent = null; 231 + const res = await app.request("/api/admin/themes", { 232 + method: "POST", 233 + headers: { "Content-Type": "application/json" }, 234 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 235 + }); 236 + expect(res.status).toBe(503); 237 + }); 238 + }); 239 + ``` 240 + 241 + **Step 3: Run to verify tests fail** 242 + 243 + ```bash 244 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 245 + ``` 246 + 247 + Expected: tests fail with something like `Cannot find description for POST /api/admin/themes` or 404 responses. 248 + 249 + --- 250 + 251 + ## Task 3: Implement `POST /api/admin/themes` 252 + 253 + **Files:** 254 + - Modify: `apps/appview/src/routes/admin.ts` 255 + 256 + **Step 1: Update imports** 257 + 258 + Add `themes, themePolicies` to the `@atbb/db` import line: 259 + 260 + ```typescript 261 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions, themes, themePolicies } from "@atbb/db"; 262 + ``` 263 + 264 + Add `or` to the `drizzle-orm` import: 265 + 266 + ```typescript 267 + import { eq, and, sql, asc, desc, count, or } from "drizzle-orm"; 268 + ``` 269 + 270 + **Step 2: Add validation helper (inline in the handler)** 271 + 272 + Add the following handler to `admin.ts` before the `return app;` at the bottom. Insert it after the DELETE `/boards/:id` handler and before the GET `/modlog` handler: 273 + 274 + ```typescript 275 + /** 276 + * POST /api/admin/themes 277 + * 278 + * Create a new theme record on Forum DID's PDS. 279 + * Writes space.atbb.forum.theme with a fresh TID rkey. 280 + * The firehose indexer creates the DB row asynchronously. 281 + */ 282 + app.post( 283 + "/themes", 284 + requireAuth(ctx), 285 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 286 + async (c) => { 287 + const { body, error: parseError } = await safeParseJsonBody(c); 288 + if (parseError) return parseError; 289 + 290 + const { name, colorScheme, tokens, cssOverrides, fontUrls } = body; 291 + 292 + if (typeof name !== "string" || name.trim().length === 0) { 293 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 294 + } 295 + if (typeof colorScheme !== "string" || (colorScheme !== "light" && colorScheme !== "dark")) { 296 + return c.json({ error: 'colorScheme is required and must be "light" or "dark"' }, 400); 297 + } 298 + if (tokens === null || tokens === undefined || typeof tokens !== "object" || Array.isArray(tokens)) { 299 + return c.json({ error: "tokens is required and must be a plain object" }, 400); 300 + } 301 + for (const [key, val] of Object.entries(tokens as Record<string, unknown>)) { 302 + if (typeof val !== "string") { 303 + return c.json({ error: `tokens["${key}"] must be a string` }, 400); 304 + } 305 + } 306 + if (cssOverrides !== undefined && typeof cssOverrides !== "string") { 307 + return c.json({ error: "cssOverrides must be a string" }, 400); 308 + } 309 + if (fontUrls !== undefined) { 310 + if (!Array.isArray(fontUrls)) { 311 + return c.json({ error: "fontUrls must be an array of strings" }, 400); 312 + } 313 + for (const url of fontUrls as unknown[]) { 314 + if (typeof url !== "string" || !url.startsWith("https://")) { 315 + return c.json({ error: "fontUrls must contain only HTTPS URLs" }, 400); 316 + } 317 + } 318 + } 319 + 320 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "POST /api/admin/themes"); 321 + if (agentError) return agentError; 322 + 323 + const rkey = TID.nextStr(); 324 + const now = new Date().toISOString(); 325 + 326 + try { 327 + const result = await agent.com.atproto.repo.putRecord({ 328 + repo: ctx.config.forumDid, 329 + collection: "space.atbb.forum.theme", 330 + rkey, 331 + record: { 332 + $type: "space.atbb.forum.theme", 333 + name: name.trim(), 334 + colorScheme, 335 + tokens, 336 + ...(typeof cssOverrides === "string" && { cssOverrides }), 337 + ...(Array.isArray(fontUrls) && { fontUrls }), 338 + createdAt: now, 339 + }, 340 + }); 341 + 342 + return c.json({ uri: result.data.uri, cid: result.data.cid }, 201); 343 + } catch (error) { 344 + return handleRouteError(c, error, "Failed to create theme", { 345 + operation: "POST /api/admin/themes", 346 + logger: ctx.logger, 347 + }); 348 + } 349 + } 350 + ); 351 + ``` 352 + 353 + **Step 3: Run tests to verify they pass** 354 + 355 + ```bash 356 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 357 + ``` 358 + 359 + Expected: POST /api/admin/themes tests all pass. 360 + 361 + **Step 4: Commit** 362 + 363 + ```bash 364 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 365 + git commit -m "feat(appview): POST /api/admin/themes — create theme on Forum PDS (ATB-57)" 366 + ``` 367 + 368 + --- 369 + 370 + ## Task 4: Write failing tests + implement `PUT /api/admin/themes/:rkey` 371 + 372 + **Files:** 373 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 374 + - Modify: `apps/appview/src/routes/admin.ts` 375 + 376 + **Step 1: Add tests for PUT** 377 + 378 + Add inside `describe.sequential("Admin Routes", ...)` in `admin.test.ts`: 379 + 380 + ```typescript 381 + describe("PUT /api/admin/themes/:rkey", () => { 382 + beforeEach(async () => { 383 + // Seed a theme row for the update tests 384 + await ctx.db.insert(themes).values({ 385 + did: ctx.config.forumDid, 386 + rkey: "3lblputtest1", 387 + cid: "bafyputtest", 388 + name: "Original Name", 389 + colorScheme: "light", 390 + tokens: { "color-bg": "#ffffff" }, 391 + cssOverrides: ".btn { font-weight: 700; }", 392 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"], 393 + createdAt: new Date("2026-01-01T00:00:00Z"), 394 + indexedAt: new Date(), 395 + }); 396 + }); 397 + 398 + it("updates theme and returns 200 with uri and cid", async () => { 399 + const res = await app.request("/api/admin/themes/3lblputtest1", { 400 + method: "PUT", 401 + headers: { "Content-Type": "application/json" }, 402 + body: JSON.stringify({ 403 + name: "Updated Name", 404 + colorScheme: "dark", 405 + tokens: { "color-bg": "#1a1a1a" }, 406 + }), 407 + }); 408 + expect(res.status).toBe(200); 409 + const body = await res.json(); 410 + expect(body.uri).toBeDefined(); 411 + expect(body.cid).toBeDefined(); 412 + }); 413 + 414 + it("preserves existing cssOverrides when not provided in request", async () => { 415 + const res = await app.request("/api/admin/themes/3lblputtest1", { 416 + method: "PUT", 417 + headers: { "Content-Type": "application/json" }, 418 + body: JSON.stringify({ 419 + name: "Updated Name", 420 + colorScheme: "light", 421 + tokens: { "color-bg": "#f0f0f0" }, 422 + // cssOverrides intentionally omitted 423 + }), 424 + }); 425 + expect(res.status).toBe(200); 426 + const call = mockPutRecord.mock.calls[0][0]; 427 + expect(call.record.cssOverrides).toBe(".btn { font-weight: 700; }"); 428 + }); 429 + 430 + it("preserves existing fontUrls when not provided in request", async () => { 431 + const res = await app.request("/api/admin/themes/3lblputtest1", { 432 + method: "PUT", 433 + headers: { "Content-Type": "application/json" }, 434 + body: JSON.stringify({ 435 + name: "Updated Name", 436 + colorScheme: "light", 437 + tokens: {}, 438 + // fontUrls intentionally omitted 439 + }), 440 + }); 441 + expect(res.status).toBe(200); 442 + const call = mockPutRecord.mock.calls[0][0]; 443 + expect(call.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 444 + }); 445 + 446 + it("preserves original createdAt in the PDS record", async () => { 447 + const res = await app.request("/api/admin/themes/3lblputtest1", { 448 + method: "PUT", 449 + headers: { "Content-Type": "application/json" }, 450 + body: JSON.stringify({ name: "Updated", colorScheme: "light", tokens: {} }), 451 + }); 452 + expect(res.status).toBe(200); 453 + const call = mockPutRecord.mock.calls[0][0]; 454 + expect(call.record.createdAt).toBe("2026-01-01T00:00:00.000Z"); 455 + }); 456 + 457 + it("returns 404 for unknown rkey", async () => { 458 + const res = await app.request("/api/admin/themes/nonexistent", { 459 + method: "PUT", 460 + headers: { "Content-Type": "application/json" }, 461 + body: JSON.stringify({ name: "X", colorScheme: "light", tokens: {} }), 462 + }); 463 + expect(res.status).toBe(404); 464 + }); 465 + 466 + it("returns 400 when name is missing", async () => { 467 + const res = await app.request("/api/admin/themes/3lblputtest1", { 468 + method: "PUT", 469 + headers: { "Content-Type": "application/json" }, 470 + body: JSON.stringify({ colorScheme: "light", tokens: {} }), 471 + }); 472 + expect(res.status).toBe(400); 473 + }); 474 + 475 + it("returns 400 when colorScheme is invalid", async () => { 476 + const res = await app.request("/api/admin/themes/3lblputtest1", { 477 + method: "PUT", 478 + headers: { "Content-Type": "application/json" }, 479 + body: JSON.stringify({ name: "Test", colorScheme: "sepia", tokens: {} }), 480 + }); 481 + expect(res.status).toBe(400); 482 + }); 483 + 484 + it("returns 400 when tokens is an array", async () => { 485 + const res = await app.request("/api/admin/themes/3lblputtest1", { 486 + method: "PUT", 487 + headers: { "Content-Type": "application/json" }, 488 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a"] }), 489 + }); 490 + expect(res.status).toBe(400); 491 + }); 492 + }); 493 + ``` 494 + 495 + **Step 2: Run to verify tests fail** 496 + 497 + ```bash 498 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 499 + ``` 500 + 501 + Expected: PUT tests fail with 404 (endpoint doesn't exist yet). 502 + 503 + **Step 3: Implement PUT handler in `admin.ts`** 504 + 505 + Add after the POST `/themes` handler: 506 + 507 + ```typescript 508 + /** 509 + * PUT /api/admin/themes/:rkey 510 + * 511 + * Update an existing theme. Fetches the existing row from DB to 512 + * preserve createdAt and fall back optional fields not in the request. 513 + * The firehose indexer updates the DB row asynchronously. 514 + */ 515 + app.put( 516 + "/themes/:rkey", 517 + requireAuth(ctx), 518 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 519 + async (c) => { 520 + const themeRkey = c.req.param("rkey").trim(); 521 + 522 + const { body, error: parseError } = await safeParseJsonBody(c); 523 + if (parseError) return parseError; 524 + 525 + const { name, colorScheme, tokens, cssOverrides, fontUrls } = body; 526 + 527 + if (typeof name !== "string" || name.trim().length === 0) { 528 + return c.json({ error: "name is required and must be a non-empty string" }, 400); 529 + } 530 + if (typeof colorScheme !== "string" || (colorScheme !== "light" && colorScheme !== "dark")) { 531 + return c.json({ error: 'colorScheme is required and must be "light" or "dark"' }, 400); 532 + } 533 + if (tokens === null || tokens === undefined || typeof tokens !== "object" || Array.isArray(tokens)) { 534 + return c.json({ error: "tokens is required and must be a plain object" }, 400); 535 + } 536 + for (const [key, val] of Object.entries(tokens as Record<string, unknown>)) { 537 + if (typeof val !== "string") { 538 + return c.json({ error: `tokens["${key}"] must be a string` }, 400); 539 + } 540 + } 541 + if (cssOverrides !== undefined && typeof cssOverrides !== "string") { 542 + return c.json({ error: "cssOverrides must be a string" }, 400); 543 + } 544 + if (fontUrls !== undefined) { 545 + if (!Array.isArray(fontUrls)) { 546 + return c.json({ error: "fontUrls must be an array of strings" }, 400); 547 + } 548 + for (const url of fontUrls as unknown[]) { 549 + if (typeof url !== "string" || !url.startsWith("https://")) { 550 + return c.json({ error: "fontUrls must contain only HTTPS URLs" }, 400); 551 + } 552 + } 553 + } 554 + 555 + let theme: typeof themes.$inferSelect; 556 + try { 557 + const [row] = await ctx.db 558 + .select() 559 + .from(themes) 560 + .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, themeRkey))) 561 + .limit(1); 562 + 563 + if (!row) { 564 + return c.json({ error: "Theme not found" }, 404); 565 + } 566 + theme = row; 567 + } catch (error) { 568 + return handleRouteError(c, error, "Failed to look up theme", { 569 + operation: "PUT /api/admin/themes/:rkey", 570 + logger: ctx.logger, 571 + themeRkey, 572 + }); 573 + } 574 + 575 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/themes/:rkey"); 576 + if (agentError) return agentError; 577 + 578 + // putRecord is a full replacement — fall back to existing values for 579 + // optional fields not provided in the request body, to avoid data loss. 580 + const resolvedCssOverrides = typeof cssOverrides === "string" ? cssOverrides : theme.cssOverrides; 581 + const resolvedFontUrls = Array.isArray(fontUrls) ? fontUrls : (theme.fontUrls as string[] | null); 582 + 583 + try { 584 + const result = await agent.com.atproto.repo.putRecord({ 585 + repo: ctx.config.forumDid, 586 + collection: "space.atbb.forum.theme", 587 + rkey: theme.rkey, 588 + record: { 589 + $type: "space.atbb.forum.theme", 590 + name: name.trim(), 591 + colorScheme, 592 + tokens, 593 + ...(resolvedCssOverrides != null && { cssOverrides: resolvedCssOverrides }), 594 + ...(resolvedFontUrls != null && { fontUrls: resolvedFontUrls }), 595 + createdAt: theme.createdAt.toISOString(), 596 + updatedAt: new Date().toISOString(), 597 + }, 598 + }); 599 + 600 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 601 + } catch (error) { 602 + return handleRouteError(c, error, "Failed to update theme", { 603 + operation: "PUT /api/admin/themes/:rkey", 604 + logger: ctx.logger, 605 + themeRkey, 606 + }); 607 + } 608 + } 609 + ); 610 + ``` 611 + 612 + **Step 4: Run tests to verify they pass** 613 + 614 + ```bash 615 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 616 + ``` 617 + 618 + Expected: all PUT tests pass. 619 + 620 + **Step 5: Commit** 621 + 622 + ```bash 623 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 624 + git commit -m "feat(appview): PUT /api/admin/themes/:rkey — update theme on Forum PDS (ATB-57)" 625 + ``` 626 + 627 + --- 628 + 629 + ## Task 5: Write failing tests + implement `DELETE /api/admin/themes/:rkey` 630 + 631 + **Files:** 632 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 633 + - Modify: `apps/appview/src/routes/admin.ts` 634 + 635 + **Step 1: Add tests** 636 + 637 + Import `themePolicies, themePolicyAvailableThemes` at the top of `admin.test.ts`: 638 + 639 + ```typescript 640 + import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions, themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 641 + ``` 642 + 643 + Then add inside `describe.sequential("Admin Routes", ...)`: 644 + 645 + ```typescript 646 + describe("DELETE /api/admin/themes/:rkey", () => { 647 + const themeRkey = "3lbldeltest1"; 648 + const themeUri = `at://${ctx?.config?.forumDid ?? "did:plc:test-forum"}/space.atbb.forum.theme/${themeRkey}`; 649 + 650 + beforeEach(async () => { 651 + await ctx.db.insert(themes).values({ 652 + did: ctx.config.forumDid, 653 + rkey: themeRkey, 654 + cid: "bafydeltest", 655 + name: "Theme To Delete", 656 + colorScheme: "light", 657 + tokens: { "color-bg": "#ffffff" }, 658 + createdAt: new Date(), 659 + indexedAt: new Date(), 660 + }); 661 + }); 662 + 663 + it("deletes theme and returns 200 with success: true", async () => { 664 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 665 + method: "DELETE", 666 + }); 667 + expect(res.status).toBe(200); 668 + const body = await res.json(); 669 + expect(body.success).toBe(true); 670 + expect(mockDeleteRecord).toHaveBeenCalledOnce(); 671 + }); 672 + 673 + it("returns 404 for unknown rkey", async () => { 674 + const res = await app.request("/api/admin/themes/doesnotexist", { 675 + method: "DELETE", 676 + }); 677 + expect(res.status).toBe(404); 678 + }); 679 + 680 + it("returns 409 when theme is the defaultLightTheme in policy", async () => { 681 + const [policy] = await ctx.db.insert(themePolicies).values({ 682 + did: ctx.config.forumDid, 683 + rkey: "self", 684 + cid: "bafypolicydel", 685 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 686 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 687 + allowUserChoice: true, 688 + indexedAt: new Date(), 689 + }).returning(); 690 + 691 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 692 + method: "DELETE", 693 + }); 694 + expect(res.status).toBe(409); 695 + const body = await res.json(); 696 + expect(body.error).toMatch(/default/i); 697 + }); 698 + 699 + it("returns 409 when theme is the defaultDarkTheme in policy", async () => { 700 + await ctx.db.insert(themePolicies).values({ 701 + did: ctx.config.forumDid, 702 + rkey: "self", 703 + cid: "bafypolicydel2", 704 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 705 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 706 + allowUserChoice: true, 707 + indexedAt: new Date(), 708 + }); 709 + 710 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 711 + method: "DELETE", 712 + }); 713 + expect(res.status).toBe(409); 714 + }); 715 + 716 + it("deletes successfully when theme exists in policy availableThemes but not as a default", async () => { 717 + // A theme can be in availableThemes without being a default — this should NOT block deletion 718 + // (the 409 guard only checks defaultLightThemeUri / defaultDarkThemeUri columns) 719 + const [policy] = await ctx.db.insert(themePolicies).values({ 720 + did: ctx.config.forumDid, 721 + rkey: "self", 722 + cid: "bafypolicyavail", 723 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 724 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 725 + allowUserChoice: true, 726 + indexedAt: new Date(), 727 + }).returning(); 728 + await ctx.db.insert(themePolicyAvailableThemes).values({ 729 + policyId: policy.id, 730 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 731 + themeCid: "bafydeltest", 732 + }); 733 + 734 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 735 + method: "DELETE", 736 + }); 737 + expect(res.status).toBe(200); 738 + }); 739 + }); 740 + ``` 741 + 742 + **Step 2: Run to verify tests fail** 743 + 744 + ```bash 745 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 746 + ``` 747 + 748 + Expected: DELETE tests fail. 749 + 750 + **Step 3: Implement DELETE handler in `admin.ts`** 751 + 752 + Add after the PUT `/themes/:rkey` handler: 753 + 754 + ```typescript 755 + /** 756 + * DELETE /api/admin/themes/:rkey 757 + * 758 + * Delete a theme. Pre-flight: refuses with 409 if the theme is set as 759 + * defaultLightTheme or defaultDarkTheme in the theme policy. 760 + * The firehose indexer removes the DB row asynchronously. 761 + */ 762 + app.delete( 763 + "/themes/:rkey", 764 + requireAuth(ctx), 765 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 766 + async (c) => { 767 + const themeRkey = c.req.param("rkey").trim(); 768 + 769 + let theme: typeof themes.$inferSelect; 770 + try { 771 + const [row] = await ctx.db 772 + .select() 773 + .from(themes) 774 + .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, themeRkey))) 775 + .limit(1); 776 + 777 + if (!row) { 778 + return c.json({ error: "Theme not found" }, 404); 779 + } 780 + theme = row; 781 + } catch (error) { 782 + return handleRouteError(c, error, "Failed to look up theme", { 783 + operation: "DELETE /api/admin/themes/:rkey", 784 + logger: ctx.logger, 785 + themeRkey, 786 + }); 787 + } 788 + 789 + // Pre-flight conflict check: refuse if this theme is a policy default 790 + const themeUri = `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`; 791 + try { 792 + const [conflictingPolicy] = await ctx.db 793 + .select({ id: themePolicies.id }) 794 + .from(themePolicies) 795 + .where( 796 + and( 797 + eq(themePolicies.did, ctx.config.forumDid), 798 + or( 799 + eq(themePolicies.defaultLightThemeUri, themeUri), 800 + eq(themePolicies.defaultDarkThemeUri, themeUri) 801 + ) 802 + ) 803 + ) 804 + .limit(1); 805 + 806 + if (conflictingPolicy) { 807 + return c.json( 808 + { error: "Cannot delete a theme that is currently set as a default. Update the theme policy first." }, 809 + 409 810 + ); 811 + } 812 + } catch (error) { 813 + return handleRouteError(c, error, "Failed to check theme policy", { 814 + operation: "DELETE /api/admin/themes/:rkey", 815 + logger: ctx.logger, 816 + themeRkey, 817 + }); 818 + } 819 + 820 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "DELETE /api/admin/themes/:rkey"); 821 + if (agentError) return agentError; 822 + 823 + try { 824 + await agent.com.atproto.repo.deleteRecord({ 825 + repo: ctx.config.forumDid, 826 + collection: "space.atbb.forum.theme", 827 + rkey: theme.rkey, 828 + }); 829 + 830 + return c.json({ success: true }); 831 + } catch (error) { 832 + return handleRouteError(c, error, "Failed to delete theme", { 833 + operation: "DELETE /api/admin/themes/:rkey", 834 + logger: ctx.logger, 835 + themeRkey, 836 + }); 837 + } 838 + } 839 + ); 840 + ``` 841 + 842 + **Step 4: Run tests to verify they pass** 843 + 844 + ```bash 845 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 846 + ``` 847 + 848 + Expected: all DELETE tests pass. 849 + 850 + **Step 5: Commit** 851 + 852 + ```bash 853 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 854 + git commit -m "feat(appview): DELETE /api/admin/themes/:rkey — delete theme, 409 if default (ATB-57)" 855 + ``` 856 + 857 + --- 858 + 859 + ## Task 6: Write failing tests + implement `PUT /api/admin/theme-policy` 860 + 861 + **Files:** 862 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 863 + - Modify: `apps/appview/src/routes/admin.ts` 864 + 865 + **Step 1: Add tests** 866 + 867 + Add inside `describe.sequential("Admin Routes", ...)`: 868 + 869 + ```typescript 870 + describe("PUT /api/admin/theme-policy", () => { 871 + const lightUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbllight1`; 872 + const darkUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbldark11`; 873 + 874 + const validBody = { 875 + availableThemes: [ 876 + { uri: lightUri, cid: "bafylight" }, 877 + { uri: darkUri, cid: "bafydark" }, 878 + ], 879 + defaultLightThemeUri: lightUri, 880 + defaultDarkThemeUri: darkUri, 881 + allowUserChoice: true, 882 + }; 883 + 884 + it("creates policy (upsert) and returns 200 with uri and cid", async () => { 885 + const res = await app.request("/api/admin/theme-policy", { 886 + method: "PUT", 887 + headers: { "Content-Type": "application/json" }, 888 + body: JSON.stringify(validBody), 889 + }); 890 + expect(res.status).toBe(200); 891 + const body = await res.json(); 892 + expect(body.uri).toBeDefined(); 893 + expect(body.cid).toBeDefined(); 894 + expect(mockPutRecord).toHaveBeenCalledOnce(); 895 + }); 896 + 897 + it("writes PDS record with themeRef wrapper structure", async () => { 898 + await app.request("/api/admin/theme-policy", { 899 + method: "PUT", 900 + headers: { "Content-Type": "application/json" }, 901 + body: JSON.stringify(validBody), 902 + }); 903 + const call = mockPutRecord.mock.calls[0][0]; 904 + expect(call.record.$type).toBe("space.atbb.forum.themePolicy"); 905 + expect(call.rkey).toBe("self"); 906 + // availableThemes wrapped in { theme: { uri, cid } } 907 + expect(call.record.availableThemes[0]).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 908 + expect(call.record.defaultLightTheme).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 909 + expect(call.record.defaultDarkTheme).toEqual({ theme: { uri: darkUri, cid: "bafydark" } }); 910 + expect(call.record.allowUserChoice).toBe(true); 911 + }); 912 + 913 + it("defaults allowUserChoice to true when not provided", async () => { 914 + const { allowUserChoice: _, ...bodyWithout } = validBody; 915 + await app.request("/api/admin/theme-policy", { 916 + method: "PUT", 917 + headers: { "Content-Type": "application/json" }, 918 + body: JSON.stringify(bodyWithout), 919 + }); 920 + const call = mockPutRecord.mock.calls[0][0]; 921 + expect(call.record.allowUserChoice).toBe(true); 922 + }); 923 + 924 + it("returns 400 when availableThemes is missing", async () => { 925 + const { availableThemes: _, ...bodyWithout } = validBody; 926 + const res = await app.request("/api/admin/theme-policy", { 927 + method: "PUT", 928 + headers: { "Content-Type": "application/json" }, 929 + body: JSON.stringify(bodyWithout), 930 + }); 931 + expect(res.status).toBe(400); 932 + const body = await res.json(); 933 + expect(body.error).toMatch(/availableThemes/i); 934 + }); 935 + 936 + it("returns 400 when availableThemes is empty array", async () => { 937 + const res = await app.request("/api/admin/theme-policy", { 938 + method: "PUT", 939 + headers: { "Content-Type": "application/json" }, 940 + body: JSON.stringify({ ...validBody, availableThemes: [] }), 941 + }); 942 + expect(res.status).toBe(400); 943 + }); 944 + 945 + it("returns 400 when availableThemes item is missing cid", async () => { 946 + const res = await app.request("/api/admin/theme-policy", { 947 + method: "PUT", 948 + headers: { "Content-Type": "application/json" }, 949 + body: JSON.stringify({ 950 + ...validBody, 951 + availableThemes: [{ uri: lightUri }], // missing cid 952 + defaultLightThemeUri: lightUri, 953 + defaultDarkThemeUri: lightUri, 954 + }), 955 + }); 956 + expect(res.status).toBe(400); 957 + }); 958 + 959 + it("returns 400 when defaultLightThemeUri is not in availableThemes", async () => { 960 + const res = await app.request("/api/admin/theme-policy", { 961 + method: "PUT", 962 + headers: { "Content-Type": "application/json" }, 963 + body: JSON.stringify({ 964 + ...validBody, 965 + defaultLightThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist", 966 + }), 967 + }); 968 + expect(res.status).toBe(400); 969 + const body = await res.json(); 970 + expect(body.error).toMatch(/defaultLightThemeUri/i); 971 + }); 972 + 973 + it("returns 400 when defaultDarkThemeUri is not in availableThemes", async () => { 974 + const res = await app.request("/api/admin/theme-policy", { 975 + method: "PUT", 976 + headers: { "Content-Type": "application/json" }, 977 + body: JSON.stringify({ 978 + ...validBody, 979 + defaultDarkThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist", 980 + }), 981 + }); 982 + expect(res.status).toBe(400); 983 + const body = await res.json(); 984 + expect(body.error).toMatch(/defaultDarkThemeUri/i); 985 + }); 986 + 987 + it("returns 400 when defaultLightThemeUri is missing", async () => { 988 + const { defaultLightThemeUri: _, ...bodyWithout } = validBody; 989 + const res = await app.request("/api/admin/theme-policy", { 990 + method: "PUT", 991 + headers: { "Content-Type": "application/json" }, 992 + body: JSON.stringify(bodyWithout), 993 + }); 994 + expect(res.status).toBe(400); 995 + }); 996 + 997 + it("returns 400 when defaultDarkThemeUri is missing", async () => { 998 + const { defaultDarkThemeUri: _, ...bodyWithout } = validBody; 999 + const res = await app.request("/api/admin/theme-policy", { 1000 + method: "PUT", 1001 + headers: { "Content-Type": "application/json" }, 1002 + body: JSON.stringify(bodyWithout), 1003 + }); 1004 + expect(res.status).toBe(400); 1005 + }); 1006 + 1007 + it("returns 503 when ForumAgent is not configured", async () => { 1008 + ctx.forumAgent = null; 1009 + const res = await app.request("/api/admin/theme-policy", { 1010 + method: "PUT", 1011 + headers: { "Content-Type": "application/json" }, 1012 + body: JSON.stringify(validBody), 1013 + }); 1014 + expect(res.status).toBe(503); 1015 + }); 1016 + }); 1017 + ``` 1018 + 1019 + **Step 2: Run to verify tests fail** 1020 + 1021 + ```bash 1022 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 1023 + ``` 1024 + 1025 + Expected: PUT /admin/theme-policy tests fail with 404. 1026 + 1027 + **Step 3: Implement PUT /theme-policy handler in `admin.ts`** 1028 + 1029 + Add after the DELETE `/themes/:rkey` handler (and before GET `/modlog`): 1030 + 1031 + ```typescript 1032 + /** 1033 + * PUT /api/admin/theme-policy 1034 + * 1035 + * Create or update the themePolicy singleton (rkey: "self") on Forum DID's PDS. 1036 + * Upsert semantics: works whether or not a policy record exists yet. 1037 + * The firehose indexer creates/updates the DB row asynchronously. 1038 + */ 1039 + app.put( 1040 + "/theme-policy", 1041 + requireAuth(ctx), 1042 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 1043 + async (c) => { 1044 + const { body, error: parseError } = await safeParseJsonBody(c); 1045 + if (parseError) return parseError; 1046 + 1047 + const { availableThemes, defaultLightThemeUri, defaultDarkThemeUri, allowUserChoice } = body; 1048 + 1049 + if (!Array.isArray(availableThemes) || availableThemes.length === 0) { 1050 + return c.json({ error: "availableThemes is required and must be a non-empty array" }, 400); 1051 + } 1052 + for (const t of availableThemes as unknown[]) { 1053 + if ( 1054 + typeof (t as any)?.uri !== "string" || 1055 + typeof (t as any)?.cid !== "string" 1056 + ) { 1057 + return c.json({ error: "Each availableThemes entry must have uri and cid string fields" }, 400); 1058 + } 1059 + } 1060 + 1061 + if (typeof defaultLightThemeUri !== "string" || !defaultLightThemeUri.startsWith("at://")) { 1062 + return c.json({ error: "defaultLightThemeUri is required and must be an AT-URI" }, 400); 1063 + } 1064 + if (typeof defaultDarkThemeUri !== "string" || !defaultDarkThemeUri.startsWith("at://")) { 1065 + return c.json({ error: "defaultDarkThemeUri is required and must be an AT-URI" }, 400); 1066 + } 1067 + 1068 + const availableUris = (availableThemes as Array<{ uri: string; cid: string }>).map((t) => t.uri); 1069 + if (!availableUris.includes(defaultLightThemeUri)) { 1070 + return c.json({ error: "defaultLightThemeUri must be present in availableThemes" }, 400); 1071 + } 1072 + if (!availableUris.includes(defaultDarkThemeUri)) { 1073 + return c.json({ error: "defaultDarkThemeUri must be present in availableThemes" }, 400); 1074 + } 1075 + 1076 + const resolvedAllowUserChoice = typeof allowUserChoice === "boolean" ? allowUserChoice : true; 1077 + 1078 + const typedAvailableThemes = availableThemes as Array<{ uri: string; cid: string }>; 1079 + const lightTheme = typedAvailableThemes.find((t) => t.uri === defaultLightThemeUri)!; 1080 + const darkTheme = typedAvailableThemes.find((t) => t.uri === defaultDarkThemeUri)!; 1081 + 1082 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/theme-policy"); 1083 + if (agentError) return agentError; 1084 + 1085 + try { 1086 + const result = await agent.com.atproto.repo.putRecord({ 1087 + repo: ctx.config.forumDid, 1088 + collection: "space.atbb.forum.themePolicy", 1089 + rkey: "self", 1090 + record: { 1091 + $type: "space.atbb.forum.themePolicy", 1092 + availableThemes: typedAvailableThemes.map((t) => ({ 1093 + theme: { uri: t.uri, cid: t.cid }, 1094 + })), 1095 + defaultLightTheme: { theme: { uri: lightTheme.uri, cid: lightTheme.cid } }, 1096 + defaultDarkTheme: { theme: { uri: darkTheme.uri, cid: darkTheme.cid } }, 1097 + allowUserChoice: resolvedAllowUserChoice, 1098 + updatedAt: new Date().toISOString(), 1099 + }, 1100 + }); 1101 + 1102 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 1103 + } catch (error) { 1104 + return handleRouteError(c, error, "Failed to update theme policy", { 1105 + operation: "PUT /api/admin/theme-policy", 1106 + logger: ctx.logger, 1107 + }); 1108 + } 1109 + } 1110 + ); 1111 + ``` 1112 + 1113 + **Step 4: Run tests to verify they pass** 1114 + 1115 + ```bash 1116 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 1117 + ``` 1118 + 1119 + Expected: all theme-policy tests pass. 1120 + 1121 + **Step 5: Run full test suite** 1122 + 1123 + ```bash 1124 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run 1125 + ``` 1126 + 1127 + Expected: all tests pass. 1128 + 1129 + **Step 6: Commit** 1130 + 1131 + ```bash 1132 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 1133 + git commit -m "feat(appview): PUT /api/admin/theme-policy — upsert policy singleton on Forum PDS (ATB-57)" 1134 + ``` 1135 + 1136 + --- 1137 + 1138 + ## Task 7: Add Bruno collection files 1139 + 1140 + **Files:** 1141 + - Create: `bruno/AppView API/Admin Themes/Create Theme.bru` 1142 + - Create: `bruno/AppView API/Admin Themes/Update Theme.bru` 1143 + - Create: `bruno/AppView API/Admin Themes/Delete Theme.bru` 1144 + - Create: `bruno/AppView API/Admin Themes/Update Theme Policy.bru` 1145 + 1146 + **Step 1: Create directory and files** 1147 + 1148 + Create `bruno/AppView API/Admin Themes/Create Theme.bru`: 1149 + 1150 + ```bru 1151 + meta { 1152 + name: Create Theme 1153 + type: http 1154 + seq: 1 1155 + } 1156 + 1157 + post { 1158 + url: {{appview_url}}/api/admin/themes 1159 + } 1160 + 1161 + body:json { 1162 + { 1163 + "name": "Neobrutal Light", 1164 + "colorScheme": "light", 1165 + "tokens": { 1166 + "color-bg": "#f5f0e8", 1167 + "color-text": "#1a1a1a", 1168 + "color-primary": "#ff5c00" 1169 + }, 1170 + "fontUrls": ["https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700"] 1171 + } 1172 + } 1173 + 1174 + assert { 1175 + res.status: eq 201 1176 + res.body.uri: isDefined 1177 + res.body.cid: isDefined 1178 + } 1179 + 1180 + docs { 1181 + Create a new theme record on the Forum DID's PDS. 1182 + The firehose indexer creates the DB row asynchronously. 1183 + 1184 + **Requires:** space.atbb.permission.manageThemes 1185 + 1186 + Body: 1187 + - name (required): Theme display name, non-empty 1188 + - colorScheme (required): "light" or "dark" 1189 + - tokens (required): Plain object of CSS design token key-value pairs. Values must be strings. 1190 + - cssOverrides (optional): Raw CSS string for structural overrides (not rendered until ATB-62 sanitization ships) 1191 + - fontUrls (optional): Array of HTTPS URLs for font stylesheets 1192 + 1193 + Returns (201): 1194 + { 1195 + "uri": "at://did:plc:.../space.atbb.forum.theme/abc123", 1196 + "cid": "bafyrei..." 1197 + } 1198 + 1199 + Error codes: 1200 + - 400: Missing name/colorScheme/tokens, invalid colorScheme, non-HTTPS fontUrl, token value not a string, malformed JSON 1201 + - 401: Not authenticated 1202 + - 403: Missing manageThemes permission 1203 + - 503: ForumAgent not configured or PDS network error 1204 + } 1205 + ``` 1206 + 1207 + Create `bruno/AppView API/Admin Themes/Update Theme.bru`: 1208 + 1209 + ```bru 1210 + meta { 1211 + name: Update Theme 1212 + type: http 1213 + seq: 2 1214 + } 1215 + 1216 + put { 1217 + url: {{appview_url}}/api/admin/themes/{{theme_rkey}} 1218 + } 1219 + 1220 + body:json { 1221 + { 1222 + "name": "Neobrutal Light (Updated)", 1223 + "colorScheme": "light", 1224 + "tokens": { 1225 + "color-bg": "#f5f0e8", 1226 + "color-text": "#1a1a1a", 1227 + "color-primary": "#ff5c00" 1228 + } 1229 + } 1230 + } 1231 + 1232 + assert { 1233 + res.status: eq 200 1234 + res.body.uri: isDefined 1235 + res.body.cid: isDefined 1236 + } 1237 + 1238 + docs { 1239 + Update an existing theme record. Full replacement of the PDS record. 1240 + Optional fields (cssOverrides, fontUrls) fall back to their existing values 1241 + when omitted from the request body. 1242 + 1243 + **Requires:** space.atbb.permission.manageThemes 1244 + 1245 + Path params: 1246 + - rkey: Theme record key (TID) 1247 + 1248 + Body: same as Create Theme (all fields). 1249 + 1250 + Returns (200): 1251 + { 1252 + "uri": "at://did:plc:.../space.atbb.forum.theme/abc123", 1253 + "cid": "bafyrei..." 1254 + } 1255 + 1256 + Error codes: 1257 + - 400: Invalid input (same as Create Theme) 1258 + - 401: Not authenticated 1259 + - 403: Missing manageThemes permission 1260 + - 404: Theme not found 1261 + - 503: ForumAgent not configured or PDS network error 1262 + } 1263 + ``` 1264 + 1265 + Create `bruno/AppView API/Admin Themes/Delete Theme.bru`: 1266 + 1267 + ```bru 1268 + meta { 1269 + name: Delete Theme 1270 + type: http 1271 + seq: 3 1272 + } 1273 + 1274 + delete { 1275 + url: {{appview_url}}/api/admin/themes/{{theme_rkey}} 1276 + } 1277 + 1278 + assert { 1279 + res.status: eq 200 1280 + res.body.success: eq true 1281 + } 1282 + 1283 + docs { 1284 + Delete a theme record. Fails with 409 if the theme is currently set as 1285 + the defaultLightTheme or defaultDarkTheme in the theme policy. 1286 + 1287 + **Requires:** space.atbb.permission.manageThemes 1288 + 1289 + Path params: 1290 + - rkey: Theme record key (TID) 1291 + 1292 + Returns (200): 1293 + { 1294 + "success": true 1295 + } 1296 + 1297 + Error codes: 1298 + - 401: Not authenticated 1299 + - 403: Missing manageThemes permission 1300 + - 404: Theme not found 1301 + - 409: Theme is the current defaultLightTheme or defaultDarkTheme — update theme policy first 1302 + - 503: ForumAgent not configured or PDS network error 1303 + } 1304 + ``` 1305 + 1306 + Create `bruno/AppView API/Admin Themes/Update Theme Policy.bru`: 1307 + 1308 + ```bru 1309 + meta { 1310 + name: Update Theme Policy 1311 + type: http 1312 + seq: 4 1313 + } 1314 + 1315 + put { 1316 + url: {{appview_url}}/api/admin/theme-policy 1317 + } 1318 + 1319 + body:json { 1320 + { 1321 + "availableThemes": [ 1322 + { "uri": "at://did:plc:example/space.atbb.forum.theme/3lbllight1", "cid": "bafylight" }, 1323 + { "uri": "at://did:plc:example/space.atbb.forum.theme/3lbldark11", "cid": "bafydark" } 1324 + ], 1325 + "defaultLightThemeUri": "at://did:plc:example/space.atbb.forum.theme/3lbllight1", 1326 + "defaultDarkThemeUri": "at://did:plc:example/space.atbb.forum.theme/3lbldark11", 1327 + "allowUserChoice": true 1328 + } 1329 + } 1330 + 1331 + assert { 1332 + res.status: eq 200 1333 + res.body.uri: isDefined 1334 + res.body.cid: isDefined 1335 + } 1336 + 1337 + docs { 1338 + Create or update the themePolicy singleton on the Forum DID's PDS. 1339 + Uses upsert semantics: works whether or not a policy record exists yet. 1340 + 1341 + **Requires:** space.atbb.permission.manageThemes 1342 + 1343 + Body: 1344 + - availableThemes (required): Non-empty array of { uri, cid } theme references. 1345 + Both defaultLightThemeUri and defaultDarkThemeUri must be present in this list. 1346 + - defaultLightThemeUri (required): AT-URI of the default light-mode theme. 1347 + Must be in availableThemes. 1348 + - defaultDarkThemeUri (required): AT-URI of the default dark-mode theme. 1349 + Must be in availableThemes. 1350 + - allowUserChoice (optional, default true): Whether users can pick their own theme. 1351 + 1352 + Returns (200): 1353 + { 1354 + "uri": "at://did:plc:.../space.atbb.forum.themePolicy/self", 1355 + "cid": "bafyrei..." 1356 + } 1357 + 1358 + Error codes: 1359 + - 400: Missing/empty availableThemes, missing defaultLightThemeUri/defaultDarkThemeUri, 1360 + default URI not in availableThemes list, malformed JSON 1361 + - 401: Not authenticated 1362 + - 403: Missing manageThemes permission 1363 + - 503: ForumAgent not configured or PDS network error 1364 + } 1365 + ``` 1366 + 1367 + **Step 2: Verify the collection directory exists and files are created** 1368 + 1369 + ```bash 1370 + ls "bruno/AppView API/Admin Themes/" 1371 + ``` 1372 + 1373 + Expected: 4 `.bru` files listed. 1374 + 1375 + **Step 3: Run full test suite one final time** 1376 + 1377 + ```bash 1378 + PATH=$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run 1379 + ``` 1380 + 1381 + Expected: all tests pass. 1382 + 1383 + **Step 4: Commit** 1384 + 1385 + ```bash 1386 + git add "bruno/AppView API/Admin Themes/" 1387 + git commit -m "docs(bruno): add Admin Themes collection for ATB-57 write endpoints" 1388 + ``` 1389 + 1390 + --- 1391 + 1392 + ## Task 8: Linear + plan doc update 1393 + 1394 + **Step 1: Mark plan doc complete** 1395 + 1396 + In `docs/plans/2026-03-02-atb-57-theme-write-api.md`, update the status line to: 1397 + ``` 1398 + **Status:** Complete (ATB-57) 1399 + ``` 1400 + 1401 + Rename/move the plan doc to `docs/plans/complete/`: 1402 + ```bash 1403 + mv docs/plans/2026-03-02-atb-57-theme-write-api.md docs/plans/complete/2026-03-02-atb-57-theme-write-api.md 1404 + mv docs/plans/2026-03-02-theme-write-api-design.md docs/plans/complete/2026-03-02-theme-write-api-design.md 1405 + ``` 1406 + 1407 + **Step 2: Commit plan doc move** 1408 + 1409 + ```bash 1410 + git add docs/plans/ 1411 + git commit -m "docs: mark ATB-57 plan docs complete, move to docs/plans/complete/" 1412 + ``` 1413 + 1414 + **Step 3: Update Linear** 1415 + 1416 + - Set ATB-57 status to **Done** 1417 + - Add a comment: "Implemented POST/PUT/DELETE /api/admin/themes and PUT /api/admin/theme-policy in admin.ts. Added manageThemes permission to Admin role in seed-roles.ts. Bruno collection added under Admin Themes/. All tests pass."
+178
docs/plans/complete/2026-03-02-theme-write-api-design.md
··· 1 + # Theme Write API Endpoints — Design 2 + 3 + **Linear:** ATB-57 4 + **Date:** 2026-03-02 5 + **Status:** Approved, ready for implementation 6 + 7 + --- 8 + 9 + ## Context 10 + 11 + The AppView needs write endpoints so admins can create, update, and delete themes, and manage the theme policy. These follow the PDS-first pattern established by category and board management. 12 + 13 + **Depends on:** ATB-51 (theme lexicons), ATB-55 (theme read endpoints + DB tables) 14 + 15 + --- 16 + 17 + ## Route Placement 18 + 19 + All four endpoints are added to `apps/appview/src/routes/admin.ts`, alongside existing category/board write endpoints. The admin router is already mounted at `/admin` in `index.ts` — no routing changes needed. 20 + 21 + --- 22 + 23 + ## Endpoints 24 + 25 + | Method | Path | Permission | 26 + |--------|------|-----------| 27 + | `POST` | `/api/admin/themes` | `space.atbb.permission.manageThemes` | 28 + | `PUT` | `/api/admin/themes/:rkey` | `space.atbb.permission.manageThemes` | 29 + | `DELETE` | `/api/admin/themes/:rkey` | `space.atbb.permission.manageThemes` | 30 + | `PUT` | `/api/admin/theme-policy` | `space.atbb.permission.manageThemes` | 31 + 32 + --- 33 + 34 + ## Permission Changes 35 + 36 + Add `space.atbb.permission.manageThemes` to `apps/appview/src/lib/seed-roles.ts`: 37 + 38 + - **Owner**: already has `"*"` wildcard — no change 39 + - **Admin**: add `manageThemes` to the permissions array 40 + - **Moderator / Member**: no change 41 + 42 + --- 43 + 44 + ## Input Validation 45 + 46 + ### Theme (POST and PUT) 47 + 48 + | Field | Rule | 49 + |-------|------| 50 + | `name` | Required string, non-empty, ≤ 100 graphemes | 51 + | `colorScheme` | Required, must be `"light"` or `"dark"` | 52 + | `tokens` | Required, must be a non-null object; values must be strings | 53 + | `cssOverrides` | Optional string (do NOT render until ATB-62 CSS sanitization ships) | 54 + | `fontUrls` | Optional array of strings; each must start with `"https://"` | 55 + 56 + Token keys are **not** validated against a known list (lenient mode — allows custom/future tokens). 57 + 58 + ### Theme Policy (PUT) 59 + 60 + | Field | Rule | 61 + |-------|------| 62 + | `availableThemes` | Required non-empty array of `{ uri: string, cid: string }` | 63 + | `defaultLightThemeUri` | Required string; must be an AT-URI present in `availableThemes` | 64 + | `defaultDarkThemeUri` | Required string; must be an AT-URI present in `availableThemes` | 65 + | `allowUserChoice` | Optional boolean, defaults `true` | 66 + 67 + --- 68 + 69 + ## Endpoint Details 70 + 71 + ### `POST /api/admin/themes` 72 + 73 + 1. Parse and validate request body 74 + 2. Get ForumAgent (return 503 if unavailable) 75 + 3. Generate `rkey = TID.nextStr()` 76 + 4. `putRecord` on Forum DID's PDS with `collection: "space.atbb.forum.theme"` 77 + 5. Return `{ uri, cid }` with `201` 78 + 79 + Does not wait for firehose indexing — the PDS write is the authoritative action. 80 + 81 + ### `PUT /api/admin/themes/:rkey` 82 + 83 + 1. Parse and validate request body 84 + 2. Look up existing theme by `rkey` + `forumDid` in DB (404 if missing) 85 + 3. Get ForumAgent 86 + 4. `putRecord` with same rkey, preserving `createdAt` from DB row 87 + 5. Optional fields (`cssOverrides`, `fontUrls`, `description`) fall back to existing DB values if not provided in request 88 + 6. Return `{ uri, cid }` with `200` 89 + 90 + ### `DELETE /api/admin/themes/:rkey` 91 + 92 + 1. Look up theme in DB (404 if missing) 93 + 2. Pre-flight conflict check: query `theme_policies` for rows where `default_light_theme_uri` OR `default_dark_theme_uri` = this theme's AT-URI 94 + 3. Return `409` if any match 95 + 4. Get ForumAgent 96 + 5. `deleteRecord` on Forum DID's PDS 97 + 6. Return `{ success: true }` with `200` 98 + 99 + ### `PUT /api/admin/theme-policy` 100 + 101 + Upsert semantics (creates if no policy row exists yet, updates if one does). 102 + 103 + 1. Parse and validate request body 104 + 2. Validate `defaultLightThemeUri` is present in `availableThemes` (400 if not) 105 + 3. Validate `defaultDarkThemeUri` is present in `availableThemes` (400 if not) 106 + 4. Get ForumAgent 107 + 5. `putRecord` with `rkey: "self"`, `collection: "space.atbb.forum.themePolicy"` 108 + 6. PDS record structure follows the `themeRef` wrapper pattern from the lexicon: `{ theme: { uri, cid } }` 109 + 7. Return `{ uri, cid }` with `200` 110 + 111 + --- 112 + 113 + ## Error Codes 114 + 115 + | Status | Condition | 116 + |--------|-----------| 117 + | 400 | Invalid/missing input field, invalid colorScheme, non-HTTPS fontUrl, default theme not in availableThemes | 118 + | 401 | Not authenticated | 119 + | 403 | Caller lacks `manageThemes` permission | 120 + | 404 | Theme rkey not found (PUT/DELETE) | 121 + | 409 | DELETE attempted on a theme that is the current policy default | 122 + | 503 | DB or PDS connectivity error | 123 + 124 + --- 125 + 126 + ## Tests 127 + 128 + ### `POST /api/admin/themes` 129 + - Happy path: returns 201 with uri and cid 130 + - Missing `name` → 400 131 + - Empty `name` → 400 132 + - `name` too long (> 100 graphemes) → 400 133 + - Invalid `colorScheme` (not light/dark) → 400 134 + - Missing `colorScheme` → 400 135 + - `tokens` not an object → 400 136 + - Missing `tokens` → 400 137 + - Non-HTTPS fontUrl → 400 138 + - Permission denied (no manageThemes) → 403 139 + - Unauthenticated → 401 140 + - PDS/DB error → 503 141 + 142 + ### `PUT /api/admin/themes/:rkey` 143 + - Happy path: updates theme, returns 200 144 + - Partial update (no cssOverrides in body) preserves existing cssOverrides 145 + - Unknown rkey → 404 146 + - Same input validation failures as POST → 400 147 + - Permission denied → 403 148 + 149 + ### `DELETE /api/admin/themes/:rkey` 150 + - Happy path: deletes theme, returns 200 151 + - Unknown rkey → 404 152 + - Theme is defaultLightTheme in policy → 409 153 + - Theme is defaultDarkTheme in policy → 409 154 + - Permission denied → 403 155 + 156 + ### `PUT /api/admin/theme-policy` 157 + - Happy path create (no existing policy): returns 200 158 + - Happy path update (policy already exists): returns 200 159 + - `defaultLightThemeUri` not in `availableThemes` → 400 160 + - `defaultDarkThemeUri` not in `availableThemes` → 400 161 + - Missing `availableThemes` → 400 162 + - Empty `availableThemes` array → 400 163 + - Missing `defaultLightThemeUri` → 400 164 + - Missing `defaultDarkThemeUri` → 400 165 + - Permission denied → 403 166 + 167 + --- 168 + 169 + ## Bruno Collection 170 + 171 + New files in `bruno/AppView API/Admin Themes/`: 172 + 173 + - `Create Theme.bru` 174 + - `Update Theme.bru` 175 + - `Delete Theme.bru` 176 + - `Update Theme Policy.bru` 177 + 178 + All use `{{appview_url}}` for the base URL and include error code documentation.