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

Malpercio 31d186e5 9b5644a8

+216 -3
+137 -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 ··· 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"); 2445 2581 }); 2446 2582 }); 2447 2583
+79 -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, 979 1056 }); 980 1057 } 981 1058 }