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): PUT /api/admin/themes/:rkey — update theme on Forum PDS (ATB-57)

Malpercio cca0b1a7 7c71a1bc

+294 -2
+190 -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 } from "@atbb/db"; 6 6 import { eq } from "drizzle-orm"; 7 7 8 8 // Mock middleware at module level ··· 2619 2619 method: "POST", 2620 2620 headers: { "Content-Type": "application/json" }, 2621 2621 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 2622 + }); 2623 + expect(res.status).toBe(503); 2624 + }); 2625 + }); 2626 + 2627 + describe("PUT /api/admin/themes/:rkey", () => { 2628 + const TEST_RKEY = "3lblputtest1"; 2629 + const TEST_CREATED_AT = new Date("2026-01-01T00:00:00Z"); 2630 + 2631 + beforeEach(async () => { 2632 + await ctx.db.insert(themes).values({ 2633 + did: ctx.config.forumDid, 2634 + rkey: TEST_RKEY, 2635 + cid: "bafythemeput", 2636 + name: "Original Theme", 2637 + colorScheme: "light", 2638 + tokens: { "color-bg": "#ffffff", "color-text": "#000000" }, 2639 + cssOverrides: ".existing { color: red; }", 2640 + fontUrls: ["https://fonts.example.com/existing.css"], 2641 + createdAt: TEST_CREATED_AT, 2642 + indexedAt: new Date(), 2643 + }); 2644 + }); 2645 + 2646 + it("updates theme and returns 200 with uri and cid", async () => { 2647 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2648 + method: "PUT", 2649 + headers: { "Content-Type": "application/json" }, 2650 + body: JSON.stringify({ 2651 + name: "Updated Theme", 2652 + colorScheme: "dark", 2653 + tokens: { "color-bg": "#1a1a1a", "color-text": "#ffffff" }, 2654 + }), 2655 + }); 2656 + expect(res.status).toBe(200); 2657 + const body = await res.json(); 2658 + expect(body.uri).toBeDefined(); 2659 + expect(body.cid).toBeDefined(); 2660 + expect(mockPutRecord).toHaveBeenCalledOnce(); 2661 + const call = mockPutRecord.mock.calls[0][0]; 2662 + expect(call.record.name).toBe("Updated Theme"); 2663 + expect(call.record.colorScheme).toBe("dark"); 2664 + expect(call.rkey).toBe(TEST_RKEY); 2665 + }); 2666 + 2667 + it("preserves existing cssOverrides when not provided in request body", async () => { 2668 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2669 + method: "PUT", 2670 + headers: { "Content-Type": "application/json" }, 2671 + body: JSON.stringify({ 2672 + name: "Updated Theme", 2673 + colorScheme: "light", 2674 + tokens: { "color-bg": "#f0f0f0" }, 2675 + }), 2676 + }); 2677 + expect(res.status).toBe(200); 2678 + const call = mockPutRecord.mock.calls[0][0]; 2679 + expect(call.record.cssOverrides).toBe(".existing { color: red; }"); 2680 + }); 2681 + 2682 + it("preserves existing fontUrls 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.fontUrls).toEqual(["https://fonts.example.com/existing.css"]); 2695 + }); 2696 + 2697 + it("preserves original createdAt in the PDS record", 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.createdAt).toBe("2026-01-01T00:00:00.000Z"); 2710 + }); 2711 + 2712 + it("returns 404 for unknown rkey", async () => { 2713 + const res = await app.request("/api/admin/themes/nonexistentkey", { 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(404); 2723 + const body = await res.json(); 2724 + expect(body.error).toMatch(/not found/i); 2725 + }); 2726 + 2727 + it("returns 400 when name is missing", async () => { 2728 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2729 + method: "PUT", 2730 + headers: { "Content-Type": "application/json" }, 2731 + body: JSON.stringify({ colorScheme: "light", tokens: {} }), 2732 + }); 2733 + expect(res.status).toBe(400); 2734 + const body = await res.json(); 2735 + expect(body.error).toMatch(/name/i); 2736 + }); 2737 + 2738 + it("returns 400 when colorScheme is invalid", async () => { 2739 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2740 + method: "PUT", 2741 + headers: { "Content-Type": "application/json" }, 2742 + body: JSON.stringify({ name: "Test", colorScheme: "sepia", tokens: {} }), 2743 + }); 2744 + expect(res.status).toBe(400); 2745 + const body = await res.json(); 2746 + expect(body.error).toMatch(/colorScheme/i); 2747 + }); 2748 + 2749 + it("returns 400 when tokens is an array", async () => { 2750 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2751 + method: "PUT", 2752 + headers: { "Content-Type": "application/json" }, 2753 + body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a", "b"] }), 2754 + }); 2755 + expect(res.status).toBe(400); 2756 + const body = await res.json(); 2757 + expect(body.error).toMatch(/tokens/i); 2758 + }); 2759 + 2760 + it("returns 401 when not authenticated", async () => { 2761 + mockUser = null; 2762 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2763 + method: "PUT", 2764 + headers: { "Content-Type": "application/json" }, 2765 + body: JSON.stringify({ 2766 + name: "Updated Theme", 2767 + colorScheme: "light", 2768 + tokens: { "color-bg": "#f0f0f0" }, 2769 + }), 2770 + }); 2771 + expect(res.status).toBe(401); 2772 + expect(mockPutRecord).not.toHaveBeenCalled(); 2773 + }); 2774 + 2775 + it("returns 403 when user lacks manageThemes permission", async () => { 2776 + const { requirePermission } = await import("../../middleware/permissions.js"); 2777 + const mockRequirePermission = requirePermission as any; 2778 + mockRequirePermission.mockImplementation(() => async (c: any) => { 2779 + return c.json({ error: "Forbidden" }, 403); 2780 + }); 2781 + 2782 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 2783 + const res = await testApp.request(`/api/admin/themes/${TEST_RKEY}`, { 2784 + method: "PUT", 2785 + headers: { "Content-Type": "application/json" }, 2786 + body: JSON.stringify({ 2787 + name: "Updated Theme", 2788 + colorScheme: "light", 2789 + tokens: { "color-bg": "#f0f0f0" }, 2790 + }), 2791 + }); 2792 + 2793 + expect(res.status).toBe(403); 2794 + expect(mockPutRecord).not.toHaveBeenCalled(); 2795 + 2796 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 2797 + await next(); 2798 + }); 2799 + }); 2800 + 2801 + it("returns 503 when PDS write fails with a network error", async () => { 2802 + mockPutRecord.mockRejectedValueOnce(new Error("fetch failed")); 2803 + const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2804 + method: "PUT", 2805 + headers: { "Content-Type": "application/json" }, 2806 + body: JSON.stringify({ 2807 + name: "Updated Theme", 2808 + colorScheme: "light", 2809 + tokens: { "color-bg": "#f0f0f0" }, 2810 + }), 2622 2811 }); 2623 2812 expect(res.status).toBe(503); 2624 2813 });
+104 -1
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"; 6 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions, themes } from "@atbb/db"; 7 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"; ··· 1053 1053 return handleRouteError(c, error, "Failed to create theme", { 1054 1054 operation: "POST /api/admin/themes", 1055 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, 1056 1159 }); 1057 1160 } 1058 1161 }