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): DELETE /api/admin/themes/:rkey — delete theme, 409 if default (ATB-57)

Malpercio e44e5d0e cca0b1a7

+217 -2
+130 -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, themes } 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 ··· 2808 2808 colorScheme: "light", 2809 2809 tokens: { "color-bg": "#f0f0f0" }, 2810 2810 }), 2811 + }); 2812 + expect(res.status).toBe(503); 2813 + }); 2814 + }); 2815 + 2816 + describe("DELETE /api/admin/themes/:rkey", () => { 2817 + const themeRkey = "3lbldeltest1"; 2818 + 2819 + beforeEach(async () => { 2820 + await ctx.db.insert(themes).values({ 2821 + did: ctx.config.forumDid, 2822 + rkey: themeRkey, 2823 + cid: "bafydeltest", 2824 + name: "Theme To Delete", 2825 + colorScheme: "light", 2826 + tokens: { "color-bg": "#ffffff" }, 2827 + createdAt: new Date(), 2828 + indexedAt: new Date(), 2829 + }); 2830 + }); 2831 + 2832 + it("deletes theme and returns 200 with success: true", async () => { 2833 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2834 + method: "DELETE", 2835 + }); 2836 + expect(res.status).toBe(200); 2837 + const body = await res.json(); 2838 + expect(body.success).toBe(true); 2839 + expect(mockDeleteRecord).toHaveBeenCalledOnce(); 2840 + }); 2841 + 2842 + it("returns 404 for unknown rkey", async () => { 2843 + const res = await app.request("/api/admin/themes/doesnotexist", { 2844 + method: "DELETE", 2845 + }); 2846 + expect(res.status).toBe(404); 2847 + }); 2848 + 2849 + it("returns 409 when theme is the defaultLightTheme in policy", async () => { 2850 + await ctx.db.insert(themePolicies).values({ 2851 + did: ctx.config.forumDid, 2852 + rkey: "self", 2853 + cid: "bafypolicydel", 2854 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 2855 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 2856 + allowUserChoice: true, 2857 + indexedAt: new Date(), 2858 + }); 2859 + 2860 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2861 + method: "DELETE", 2862 + }); 2863 + expect(res.status).toBe(409); 2864 + const body = await res.json(); 2865 + expect(body.error).toMatch(/default/i); 2866 + }); 2867 + 2868 + it("returns 409 when theme is the defaultDarkTheme in policy", async () => { 2869 + await ctx.db.insert(themePolicies).values({ 2870 + did: ctx.config.forumDid, 2871 + rkey: "self", 2872 + cid: "bafypolicydel2", 2873 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 2874 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 2875 + allowUserChoice: true, 2876 + indexedAt: new Date(), 2877 + }); 2878 + 2879 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2880 + method: "DELETE", 2881 + }); 2882 + expect(res.status).toBe(409); 2883 + }); 2884 + 2885 + it("deletes successfully when theme exists in policy availableThemes but not as a default", async () => { 2886 + const [policy] = await ctx.db.insert(themePolicies).values({ 2887 + did: ctx.config.forumDid, 2888 + rkey: "self", 2889 + cid: "bafypolicyavail", 2890 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 2891 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 2892 + allowUserChoice: true, 2893 + indexedAt: new Date(), 2894 + }).returning(); 2895 + await ctx.db.insert(themePolicyAvailableThemes).values({ 2896 + policyId: policy.id, 2897 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 2898 + themeCid: "bafydeltest", 2899 + }); 2900 + 2901 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2902 + method: "DELETE", 2903 + }); 2904 + expect(res.status).toBe(200); 2905 + }); 2906 + 2907 + it("returns 401 when not authenticated", async () => { 2908 + mockUser = null; 2909 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2910 + method: "DELETE", 2911 + }); 2912 + expect(res.status).toBe(401); 2913 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 2914 + }); 2915 + 2916 + it("returns 403 when user lacks manageThemes permission", async () => { 2917 + const { requirePermission } = await import("../../middleware/permissions.js"); 2918 + const mockRequirePermission = requirePermission as any; 2919 + mockRequirePermission.mockImplementation(() => async (c: any) => { 2920 + return c.json({ error: "Forbidden" }, 403); 2921 + }); 2922 + 2923 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 2924 + const res = await testApp.request(`/api/admin/themes/${themeRkey}`, { 2925 + method: "DELETE", 2926 + }); 2927 + 2928 + expect(res.status).toBe(403); 2929 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 2930 + 2931 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 2932 + await next(); 2933 + }); 2934 + }); 2935 + 2936 + it("returns 503 when PDS delete fails with a network error", async () => { 2937 + mockDeleteRecord.mockRejectedValueOnce(new Error("fetch failed")); 2938 + const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2939 + method: "DELETE", 2811 2940 }); 2812 2941 expect(res.status).toBe(503); 2813 2942 });
+87 -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, themes } from "@atbb/db"; 6 + import { memberships, roles, rolePermissions, users, forums, backfillProgress, backfillErrors, categories, boards, posts, modActions, themes, themePolicies } 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"; ··· 1154 1154 } catch (error) { 1155 1155 return handleRouteError(c, error, "Failed to update theme", { 1156 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", 1157 1243 logger: ctx.logger, 1158 1244 themeRkey, 1159 1245 });