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/theme-policy — upsert policy singleton on Forum PDS (ATB-57)

Malpercio 88b14d8f 94f5613d

+263
+183
apps/appview/src/routes/__tests__/admin.test.ts
··· 2948 2948 }); 2949 2949 }); 2950 2950 2951 + describe("PUT /api/admin/theme-policy", () => { 2952 + const lightUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbllight1`; 2953 + const darkUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbldark11`; 2954 + 2955 + const validBody = { 2956 + availableThemes: [ 2957 + { uri: lightUri, cid: "bafylight" }, 2958 + { uri: darkUri, cid: "bafydark" }, 2959 + ], 2960 + defaultLightThemeUri: lightUri, 2961 + defaultDarkThemeUri: darkUri, 2962 + allowUserChoice: true, 2963 + }; 2964 + 2965 + it("creates policy (upsert) and returns 200 with uri and cid", async () => { 2966 + const res = await app.request("/api/admin/theme-policy", { 2967 + method: "PUT", 2968 + headers: { "Content-Type": "application/json" }, 2969 + body: JSON.stringify(validBody), 2970 + }); 2971 + expect(res.status).toBe(200); 2972 + const body = await res.json(); 2973 + expect(body.uri).toBeDefined(); 2974 + expect(body.cid).toBeDefined(); 2975 + expect(mockPutRecord).toHaveBeenCalledOnce(); 2976 + }); 2977 + 2978 + it("writes PDS record with themeRef wrapper structure", async () => { 2979 + await app.request("/api/admin/theme-policy", { 2980 + method: "PUT", 2981 + headers: { "Content-Type": "application/json" }, 2982 + body: JSON.stringify(validBody), 2983 + }); 2984 + const call = mockPutRecord.mock.calls[0][0]; 2985 + expect(call.record.$type).toBe("space.atbb.forum.themePolicy"); 2986 + expect(call.rkey).toBe("self"); 2987 + // availableThemes wrapped in { theme: { uri, cid } } 2988 + expect(call.record.availableThemes[0]).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 2989 + expect(call.record.defaultLightTheme).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 2990 + expect(call.record.defaultDarkTheme).toEqual({ theme: { uri: darkUri, cid: "bafydark" } }); 2991 + expect(call.record.allowUserChoice).toBe(true); 2992 + }); 2993 + 2994 + it("defaults allowUserChoice to true when not provided", async () => { 2995 + const { allowUserChoice: _, ...bodyWithout } = validBody; 2996 + await app.request("/api/admin/theme-policy", { 2997 + method: "PUT", 2998 + headers: { "Content-Type": "application/json" }, 2999 + body: JSON.stringify(bodyWithout), 3000 + }); 3001 + const call = mockPutRecord.mock.calls[0][0]; 3002 + expect(call.record.allowUserChoice).toBe(true); 3003 + }); 3004 + 3005 + it("returns 400 when availableThemes is missing", async () => { 3006 + const { availableThemes: _, ...bodyWithout } = validBody; 3007 + const res = await app.request("/api/admin/theme-policy", { 3008 + method: "PUT", 3009 + headers: { "Content-Type": "application/json" }, 3010 + body: JSON.stringify(bodyWithout), 3011 + }); 3012 + expect(res.status).toBe(400); 3013 + const body = await res.json(); 3014 + expect(body.error).toMatch(/availableThemes/i); 3015 + }); 3016 + 3017 + it("returns 400 when availableThemes is empty array", async () => { 3018 + const res = await app.request("/api/admin/theme-policy", { 3019 + method: "PUT", 3020 + headers: { "Content-Type": "application/json" }, 3021 + body: JSON.stringify({ ...validBody, availableThemes: [] }), 3022 + }); 3023 + expect(res.status).toBe(400); 3024 + }); 3025 + 3026 + it("returns 400 when availableThemes item is missing cid", async () => { 3027 + const res = await app.request("/api/admin/theme-policy", { 3028 + method: "PUT", 3029 + headers: { "Content-Type": "application/json" }, 3030 + body: JSON.stringify({ 3031 + ...validBody, 3032 + availableThemes: [{ uri: lightUri }], // missing cid 3033 + defaultLightThemeUri: lightUri, 3034 + defaultDarkThemeUri: lightUri, 3035 + }), 3036 + }); 3037 + expect(res.status).toBe(400); 3038 + }); 3039 + 3040 + it("returns 400 when defaultLightThemeUri is not in availableThemes", async () => { 3041 + const res = await app.request("/api/admin/theme-policy", { 3042 + method: "PUT", 3043 + headers: { "Content-Type": "application/json" }, 3044 + body: JSON.stringify({ 3045 + ...validBody, 3046 + defaultLightThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist", 3047 + }), 3048 + }); 3049 + expect(res.status).toBe(400); 3050 + const body = await res.json(); 3051 + expect(body.error).toMatch(/defaultLightThemeUri/i); 3052 + }); 3053 + 3054 + it("returns 400 when defaultDarkThemeUri is not in availableThemes", async () => { 3055 + const res = await app.request("/api/admin/theme-policy", { 3056 + method: "PUT", 3057 + headers: { "Content-Type": "application/json" }, 3058 + body: JSON.stringify({ 3059 + ...validBody, 3060 + defaultDarkThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist", 3061 + }), 3062 + }); 3063 + expect(res.status).toBe(400); 3064 + const body = await res.json(); 3065 + expect(body.error).toMatch(/defaultDarkThemeUri/i); 3066 + }); 3067 + 3068 + it("returns 400 when defaultLightThemeUri is missing", async () => { 3069 + const { defaultLightThemeUri: _, ...bodyWithout } = validBody; 3070 + const res = await app.request("/api/admin/theme-policy", { 3071 + method: "PUT", 3072 + headers: { "Content-Type": "application/json" }, 3073 + body: JSON.stringify(bodyWithout), 3074 + }); 3075 + expect(res.status).toBe(400); 3076 + }); 3077 + 3078 + it("returns 400 when defaultDarkThemeUri is missing", async () => { 3079 + const { defaultDarkThemeUri: _, ...bodyWithout } = validBody; 3080 + const res = await app.request("/api/admin/theme-policy", { 3081 + method: "PUT", 3082 + headers: { "Content-Type": "application/json" }, 3083 + body: JSON.stringify(bodyWithout), 3084 + }); 3085 + expect(res.status).toBe(400); 3086 + }); 3087 + 3088 + it("returns 401 when not authenticated", async () => { 3089 + mockUser = null; 3090 + const res = await app.request("/api/admin/theme-policy", { 3091 + method: "PUT", 3092 + headers: { "Content-Type": "application/json" }, 3093 + body: JSON.stringify(validBody), 3094 + }); 3095 + expect(res.status).toBe(401); 3096 + expect(mockPutRecord).not.toHaveBeenCalled(); 3097 + }); 3098 + 3099 + it("returns 403 when user lacks manageThemes permission", async () => { 3100 + const { requirePermission } = await import("../../middleware/permissions.js"); 3101 + const mockRequirePermission = requirePermission as any; 3102 + mockRequirePermission.mockImplementation(() => async (c: any) => { 3103 + return c.json({ error: "Forbidden" }, 403); 3104 + }); 3105 + 3106 + const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 3107 + const res = await testApp.request("/api/admin/theme-policy", { 3108 + method: "PUT", 3109 + headers: { "Content-Type": "application/json" }, 3110 + body: JSON.stringify(validBody), 3111 + }); 3112 + 3113 + expect(res.status).toBe(403); 3114 + expect(mockPutRecord).not.toHaveBeenCalled(); 3115 + 3116 + mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 3117 + await next(); 3118 + }); 3119 + }); 3120 + 3121 + it("returns 500 when ForumAgent is not configured", async () => { 3122 + ctx.forumAgent = null; 3123 + const res = await app.request("/api/admin/theme-policy", { 3124 + method: "PUT", 3125 + headers: { "Content-Type": "application/json" }, 3126 + body: JSON.stringify(validBody), 3127 + }); 3128 + expect(res.status).toBe(500); 3129 + const body = await res.json(); 3130 + expect(body.error).toContain("Forum agent not available"); 3131 + }); 3132 + }); 3133 + 2951 3134 }); 2952 3135
+80
apps/appview/src/routes/admin.ts
··· 1248 1248 ); 1249 1249 1250 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 as any)?.uri !== "string" || 1273 + typeof (t as any)?.cid !== "string" 1274 + ) { 1275 + return c.json({ error: "Each availableThemes entry must have uri and cid string fields" }, 400); 1276 + } 1277 + } 1278 + 1279 + if (typeof defaultLightThemeUri !== "string" || !defaultLightThemeUri.startsWith("at://")) { 1280 + return c.json({ error: "defaultLightThemeUri is required and must be an AT-URI" }, 400); 1281 + } 1282 + if (typeof defaultDarkThemeUri !== "string" || !defaultDarkThemeUri.startsWith("at://")) { 1283 + return c.json({ error: "defaultDarkThemeUri is required and must be an AT-URI" }, 400); 1284 + } 1285 + 1286 + const availableUris = (availableThemes as Array<{ uri: string; cid: string }>).map((t) => t.uri); 1287 + if (!availableUris.includes(defaultLightThemeUri)) { 1288 + return c.json({ error: "defaultLightThemeUri must be present in availableThemes" }, 400); 1289 + } 1290 + if (!availableUris.includes(defaultDarkThemeUri)) { 1291 + return c.json({ error: "defaultDarkThemeUri must be present in availableThemes" }, 400); 1292 + } 1293 + 1294 + const resolvedAllowUserChoice = typeof allowUserChoice === "boolean" ? allowUserChoice : true; 1295 + 1296 + const typedAvailableThemes = availableThemes as Array<{ uri: string; cid: string }>; 1297 + const lightTheme = typedAvailableThemes.find((t) => t.uri === defaultLightThemeUri)!; 1298 + const darkTheme = typedAvailableThemes.find((t) => t.uri === defaultDarkThemeUri)!; 1299 + 1300 + const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/theme-policy"); 1301 + if (agentError) return agentError; 1302 + 1303 + try { 1304 + const result = await agent.com.atproto.repo.putRecord({ 1305 + repo: ctx.config.forumDid, 1306 + collection: "space.atbb.forum.themePolicy", 1307 + rkey: "self", 1308 + record: { 1309 + $type: "space.atbb.forum.themePolicy", 1310 + availableThemes: typedAvailableThemes.map((t) => ({ 1311 + theme: { uri: t.uri, cid: t.cid }, 1312 + })), 1313 + defaultLightTheme: { theme: { uri: lightTheme.uri, cid: lightTheme.cid } }, 1314 + defaultDarkTheme: { theme: { uri: darkTheme.uri, cid: darkTheme.cid } }, 1315 + allowUserChoice: resolvedAllowUserChoice, 1316 + updatedAt: new Date().toISOString(), 1317 + }, 1318 + }); 1319 + 1320 + return c.json({ uri: result.data.uri, cid: result.data.cid }); 1321 + } catch (error) { 1322 + return handleRouteError(c, error, "Failed to update theme policy", { 1323 + operation: "PUT /api/admin/theme-policy", 1324 + logger: ctx.logger, 1325 + }); 1326 + } 1327 + } 1328 + ); 1329 + 1330 + /** 1251 1331 * GET /api/admin/modlog 1252 1332 * 1253 1333 * Paginated, reverse-chronological list of mod actions.