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): add GET /api/themes, /api/themes/:rkey, /api/theme-policy endpoints (ATB-55)

Malpercio 2d42f845 0ab88db9

+457 -2
+9 -1
apps/appview/src/lib/__tests__/test-context.ts
··· 1 1 import { eq, or, like } from "drizzle-orm"; 2 2 import { createDb, runSqliteMigrations } from "@atbb/db"; 3 - import { forums, posts, users, categories, memberships, boards, roles, modActions, backfillProgress, backfillErrors } from "@atbb/db"; 3 + import { forums, posts, users, categories, memberships, boards, roles, modActions, backfillProgress, backfillErrors, themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 4 4 import { createLogger } from "@atbb/logger"; 5 5 import path from "path"; 6 6 import { fileURLToPath } from "url"; ··· 92 92 await db.delete(modActions).catch(() => {}); 93 93 await db.delete(backfillErrors).catch(() => {}); 94 94 await db.delete(backfillProgress).catch(() => {}); 95 + await db.delete(themePolicyAvailableThemes).catch(() => {}); 96 + await db.delete(themePolicies).catch(() => {}); // cascades to theme_policy_available_themes 97 + await db.delete(themes).catch(() => {}); 95 98 await db.delete(forums).catch(() => {}); 96 99 return; 97 100 } ··· 109 112 await db.delete(modActions).where(eq(modActions.did, config.forumDid)).catch(() => {}); 110 113 await db.delete(backfillErrors).catch(() => {}); 111 114 await db.delete(backfillProgress).catch(() => {}); 115 + // Deleting themePolicies cascades to theme_policy_available_themes 116 + await db.delete(themePolicies).where(eq(themePolicies.did, config.forumDid)).catch(() => {}); 117 + await db.delete(themes).where(eq(themes.did, config.forumDid)).catch(() => {}); 112 118 await db.delete(forums).where(eq(forums.did, config.forumDid)).catch(() => {}); 113 119 }; 114 120 ··· 181 187 await db.delete(boards).where(eq(boards.did, config.forumDid)); 182 188 await db.delete(categories).where(eq(categories.did, config.forumDid)); 183 189 await db.delete(roles).where(eq(roles.did, config.forumDid)); // cascades to role_permissions 190 + await db.delete(themePolicies).where(eq(themePolicies.did, config.forumDid)); 191 + await db.delete(themes).where(eq(themes.did, config.forumDid)); 184 192 await db.delete(modActions).where(eq(modActions.did, config.forumDid)); 185 193 await db.delete(backfillErrors).catch(() => {}); 186 194 await db.delete(backfillProgress).catch(() => {});
+292
apps/appview/src/routes/__tests__/themes.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 2 + import { Hono } from "hono"; 3 + import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 4 + import { createThemesRoutes, createThemePolicyRoutes } from "../themes.js"; 5 + import { themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 6 + 7 + // ── GET /api/themes ────────────────────────────────────── 8 + 9 + describe("GET /api/themes", () => { 10 + let ctx: TestContext; 11 + let app: Hono; 12 + 13 + beforeEach(async () => { 14 + ctx = await createTestContext(); 15 + app = new Hono() 16 + .route("/themes", createThemesRoutes(ctx)) 17 + .route("/theme-policy", createThemePolicyRoutes(ctx)); 18 + }); 19 + 20 + afterEach(async () => { 21 + await ctx.cleanup(); 22 + }); 23 + 24 + it("returns empty array when no policy exists", async () => { 25 + const res = await app.request("/themes"); 26 + expect(res.status).toBe(200); 27 + const body = await res.json(); 28 + expect(body).toHaveProperty("themes"); 29 + expect(body.themes).toEqual([]); 30 + }); 31 + 32 + it("returns only themes listed in availableThemes (not all themes in DB)", async () => { 33 + await ctx.db.insert(themes).values([ 34 + { 35 + did: ctx.config.forumDid, 36 + rkey: "3lbltheme1aa", 37 + cid: "bafytheme1", 38 + name: "Neobrutal Light", 39 + colorScheme: "light", 40 + tokens: { "color-bg": "#f5f0e8" }, 41 + createdAt: new Date(), 42 + indexedAt: new Date(), 43 + }, 44 + { 45 + did: ctx.config.forumDid, 46 + rkey: "3lbltheme2bb", 47 + cid: "bafytheme2", 48 + name: "Neobrutal Dark", 49 + colorScheme: "dark", 50 + tokens: { "color-bg": "#1a1a1a" }, 51 + createdAt: new Date(), 52 + indexedAt: new Date(), 53 + }, 54 + ]); 55 + 56 + const [policy] = await ctx.db 57 + .insert(themePolicies) 58 + .values({ 59 + did: ctx.config.forumDid, 60 + rkey: "self", 61 + cid: "bafypolicy1", 62 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme1aa`, 63 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme2bb`, 64 + allowUserChoice: true, 65 + indexedAt: new Date(), 66 + }) 67 + .returning(); 68 + 69 + // Only expose theme1 (light) — theme2 (dark) stays hidden 70 + await ctx.db.insert(themePolicyAvailableThemes).values({ 71 + policyId: policy.id, 72 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme1aa`, 73 + themeCid: "bafytheme1", 74 + }); 75 + 76 + const res = await app.request("/themes"); 77 + expect(res.status).toBe(200); 78 + const body = await res.json(); 79 + 80 + expect(body.themes).toHaveLength(1); 81 + expect(body.themes[0].name).toBe("Neobrutal Light"); 82 + // theme2 is in DB but NOT in availableThemes — must not appear 83 + const names = body.themes.map((t: any) => t.name); 84 + expect(names).not.toContain("Neobrutal Dark"); 85 + }); 86 + 87 + it("returns summary shape (id, uri, name, colorScheme, indexedAt — no tokens or cssOverrides)", async () => { 88 + await ctx.db.insert(themes).values({ 89 + did: ctx.config.forumDid, 90 + rkey: "3lbltheme3cc", 91 + cid: "bafytheme3", 92 + name: "Clean Light", 93 + colorScheme: "light", 94 + tokens: { "color-bg": "#ffffff" }, 95 + cssOverrides: ".card { border-radius: 4px; }", 96 + createdAt: new Date(), 97 + indexedAt: new Date(), 98 + }); 99 + 100 + const [policy] = await ctx.db 101 + .insert(themePolicies) 102 + .values({ 103 + did: ctx.config.forumDid, 104 + rkey: "self", 105 + cid: "bafypolicy2", 106 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 107 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 108 + allowUserChoice: true, 109 + indexedAt: new Date(), 110 + }) 111 + .returning(); 112 + 113 + await ctx.db.insert(themePolicyAvailableThemes).values({ 114 + policyId: policy.id, 115 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme3cc`, 116 + themeCid: "bafytheme3", 117 + }); 118 + 119 + const res = await app.request("/themes"); 120 + const body = await res.json(); 121 + const theme = body.themes[0]; 122 + 123 + expect(theme).toHaveProperty("id"); 124 + expect(theme).toHaveProperty("uri"); 125 + expect(theme.uri).toContain("/space.atbb.forum.theme/3lbltheme3cc"); 126 + expect(theme).toHaveProperty("name"); 127 + expect(theme).toHaveProperty("colorScheme"); 128 + expect(theme).toHaveProperty("indexedAt"); 129 + // List endpoint must NOT return full token set 130 + expect(theme).not.toHaveProperty("tokens"); 131 + expect(theme).not.toHaveProperty("cssOverrides"); 132 + expect(theme).not.toHaveProperty("fontUrls"); 133 + }); 134 + 135 + it("returns 503 on database error", async () => { 136 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 137 + throw new Error("Database connection lost"); 138 + }); 139 + 140 + const res = await app.request("/themes"); 141 + expect(res.status).toBe(503); 142 + }); 143 + }); 144 + 145 + // ── GET /api/themes/:rkey ──────────────────────────────── 146 + 147 + describe("GET /api/themes/:rkey", () => { 148 + let ctx: TestContext; 149 + let app: Hono; 150 + 151 + beforeEach(async () => { 152 + ctx = await createTestContext(); 153 + app = new Hono().route("/themes", createThemesRoutes(ctx)); 154 + }); 155 + 156 + afterEach(async () => { 157 + await ctx.cleanup(); 158 + }); 159 + 160 + it("returns 404 for unknown rkey", async () => { 161 + const res = await app.request("/themes/nonexistent"); 162 + expect(res.status).toBe(404); 163 + const body = await res.json(); 164 + expect(body.error).toBeDefined(); 165 + }); 166 + 167 + it("returns full theme data including tokens, cssOverrides, and fontUrls", async () => { 168 + await ctx.db.insert(themes).values({ 169 + did: ctx.config.forumDid, 170 + rkey: "3lblfulltest", 171 + cid: "bafyfull", 172 + name: "Neobrutal Light", 173 + colorScheme: "light", 174 + tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 175 + cssOverrides: ".btn { font-weight: 700; }", 176 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"], 177 + createdAt: new Date(), 178 + indexedAt: new Date(), 179 + }); 180 + 181 + const res = await app.request("/themes/3lblfulltest"); 182 + expect(res.status).toBe(200); 183 + const body = await res.json(); 184 + 185 + expect(body.name).toBe("Neobrutal Light"); 186 + expect(body.colorScheme).toBe("light"); 187 + expect(body.tokens).toEqual({ "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }); 188 + expect(body.cssOverrides).toBe(".btn { font-weight: 700; }"); 189 + expect(body.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 190 + expect(body.uri).toContain("/space.atbb.forum.theme/3lblfulltest"); 191 + expect(body.indexedAt).toBeDefined(); 192 + }); 193 + 194 + it("returns null for optional fields when not set", async () => { 195 + await ctx.db.insert(themes).values({ 196 + did: ctx.config.forumDid, 197 + rkey: "3lblminimal", 198 + cid: "bafymin", 199 + name: "Minimal", 200 + colorScheme: "light", 201 + tokens: { "color-bg": "#fff" }, 202 + createdAt: new Date(), 203 + indexedAt: new Date(), 204 + }); 205 + 206 + const res = await app.request("/themes/3lblminimal"); 207 + expect(res.status).toBe(200); 208 + const body = await res.json(); 209 + expect(body.cssOverrides).toBeNull(); 210 + expect(body.fontUrls).toBeNull(); 211 + }); 212 + 213 + it("returns 503 on database error", async () => { 214 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 215 + throw new Error("Database connection lost"); 216 + }); 217 + 218 + const res = await app.request("/themes/any-rkey"); 219 + expect(res.status).toBe(503); 220 + }); 221 + }); 222 + 223 + // ── GET /api/theme-policy ──────────────────────────────── 224 + 225 + describe("GET /api/theme-policy", () => { 226 + let ctx: TestContext; 227 + let app: Hono; 228 + 229 + beforeEach(async () => { 230 + ctx = await createTestContext(); 231 + app = new Hono().route("/theme-policy", createThemePolicyRoutes(ctx)); 232 + }); 233 + 234 + afterEach(async () => { 235 + await ctx.cleanup(); 236 + }); 237 + 238 + it("returns 404 when no policy exists", async () => { 239 + const res = await app.request("/theme-policy"); 240 + expect(res.status).toBe(404); 241 + const body = await res.json(); 242 + expect(body.error).toBeDefined(); 243 + }); 244 + 245 + it("returns policy with correct fields", async () => { 246 + const [policy] = await ctx.db 247 + .insert(themePolicies) 248 + .values({ 249 + did: ctx.config.forumDid, 250 + rkey: "self", 251 + cid: "bafypol", 252 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbllight`, 253 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbldark`, 254 + allowUserChoice: false, 255 + indexedAt: new Date(), 256 + }) 257 + .returning(); 258 + 259 + await ctx.db.insert(themePolicyAvailableThemes).values([ 260 + { 261 + policyId: policy.id, 262 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbllight`, 263 + themeCid: "bafylight", 264 + }, 265 + { 266 + policyId: policy.id, 267 + themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbldark`, 268 + themeCid: "bafydark", 269 + }, 270 + ]); 271 + 272 + const res = await app.request("/theme-policy"); 273 + expect(res.status).toBe(200); 274 + const body = await res.json(); 275 + 276 + expect(body.defaultLightThemeUri).toContain("3lbllight"); 277 + expect(body.defaultDarkThemeUri).toContain("3lbldark"); 278 + expect(body.allowUserChoice).toBe(false); 279 + expect(body.availableThemes).toHaveLength(2); 280 + expect(body.availableThemes[0]).toHaveProperty("uri"); 281 + expect(body.availableThemes[0]).toHaveProperty("cid"); 282 + }); 283 + 284 + it("returns 503 on database error", async () => { 285 + vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 286 + throw new Error("Database connection lost"); 287 + }); 288 + 289 + const res = await app.request("/theme-policy"); 290 + expect(res.status).toBe(503); 291 + }); 292 + });
+4 -1
apps/appview/src/routes/index.ts
··· 9 9 import { createAuthRoutes } from "./auth.js"; 10 10 import { createAdminRoutes } from "./admin.js"; 11 11 import { createModRoutes } from "./mod.js"; 12 + import { createThemesRoutes, createThemePolicyRoutes } from "./themes.js"; 12 13 13 14 /** 14 15 * Factory function that creates all API routes with access to app context. ··· 24 25 .route("/topics", createTopicsRoutes(ctx)) 25 26 .route("/posts", createPostsRoutes(ctx)) 26 27 .route("/admin", createAdminRoutes(ctx)) 27 - .route("/mod", createModRoutes(ctx)); 28 + .route("/mod", createModRoutes(ctx)) 29 + .route("/themes", createThemesRoutes(ctx)) 30 + .route("/theme-policy", createThemePolicyRoutes(ctx)); 28 31 } 29 32 30 33 // Export stub routes for tests that don't need database access
+152
apps/appview/src/routes/themes.ts
··· 1 + import { Hono } from "hono"; 2 + import type { AppContext } from "../lib/app-context.js"; 3 + import { themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 4 + import { eq, inArray, and } from "drizzle-orm"; 5 + import { serializeBigInt, serializeDate } from "./helpers.js"; 6 + import { handleRouteError } from "../lib/route-errors.js"; 7 + import { parseAtUri } from "../lib/at-uri.js"; 8 + 9 + type ThemeRow = typeof themes.$inferSelect; 10 + 11 + function serializeThemeSummary(theme: ThemeRow) { 12 + return { 13 + id: serializeBigInt(theme.id), 14 + uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 15 + name: theme.name, 16 + colorScheme: theme.colorScheme, 17 + indexedAt: serializeDate(theme.indexedAt), 18 + }; 19 + } 20 + 21 + function serializeThemeFull(theme: ThemeRow) { 22 + return { 23 + id: serializeBigInt(theme.id), 24 + uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 25 + name: theme.name, 26 + colorScheme: theme.colorScheme, 27 + tokens: theme.tokens, 28 + cssOverrides: theme.cssOverrides ?? null, 29 + fontUrls: (theme.fontUrls as string[] | null) ?? null, 30 + createdAt: serializeDate(theme.createdAt), 31 + indexedAt: serializeDate(theme.indexedAt), 32 + }; 33 + } 34 + 35 + export function createThemesRoutes(ctx: AppContext) { 36 + return new Hono() 37 + .get("/", async (c) => { 38 + try { 39 + // Step 1: Get available theme URIs from this forum's policy 40 + const availableRows = await ctx.db 41 + .select({ themeUri: themePolicyAvailableThemes.themeUri }) 42 + .from(themePolicyAvailableThemes) 43 + .innerJoin( 44 + themePolicies, 45 + eq(themePolicies.id, themePolicyAvailableThemes.policyId) 46 + ) 47 + .where(eq(themePolicies.did, ctx.config.forumDid)); 48 + 49 + if (availableRows.length === 0) { 50 + return c.json({ themes: [] }); 51 + } 52 + 53 + // Step 2: Parse rkeys from AT-URIs 54 + const rkeys = availableRows 55 + .map((r) => parseAtUri(r.themeUri)?.rkey) 56 + .filter((rkey): rkey is string => !!rkey); 57 + 58 + if (rkeys.length === 0) { 59 + return c.json({ themes: [] }); 60 + } 61 + 62 + // Step 3: Fetch matching themes 63 + const themeList = await ctx.db 64 + .select() 65 + .from(themes) 66 + .where( 67 + and( 68 + eq(themes.did, ctx.config.forumDid), 69 + inArray(themes.rkey, rkeys) 70 + ) 71 + ) 72 + .limit(100); 73 + 74 + return c.json({ themes: themeList.map(serializeThemeSummary) }); 75 + } catch (error) { 76 + return handleRouteError(c, error, "Failed to retrieve themes", { 77 + operation: "GET /api/themes", 78 + logger: ctx.logger, 79 + }); 80 + } 81 + }) 82 + .get("/:rkey", async (c) => { 83 + const rkey = c.req.param("rkey").trim(); 84 + if (!rkey) { 85 + return c.json({ error: "Invalid theme rkey" }, 400); 86 + } 87 + 88 + try { 89 + const [theme] = await ctx.db 90 + .select() 91 + .from(themes) 92 + .where( 93 + and( 94 + eq(themes.did, ctx.config.forumDid), 95 + eq(themes.rkey, rkey) 96 + ) 97 + ) 98 + .limit(1); 99 + 100 + if (!theme) { 101 + return c.json({ error: "Theme not found" }, 404); 102 + } 103 + 104 + return c.json(serializeThemeFull(theme)); 105 + } catch (error) { 106 + return handleRouteError(c, error, "Failed to retrieve theme", { 107 + operation: "GET /api/themes/:rkey", 108 + logger: ctx.logger, 109 + themeRkey: rkey, 110 + }); 111 + } 112 + }); 113 + } 114 + 115 + export function createThemePolicyRoutes(ctx: AppContext) { 116 + return new Hono().get("/", async (c) => { 117 + try { 118 + const [policy] = await ctx.db 119 + .select() 120 + .from(themePolicies) 121 + .where(eq(themePolicies.did, ctx.config.forumDid)) 122 + .limit(1); 123 + 124 + if (!policy) { 125 + return c.json({ error: "Theme policy not found" }, 404); 126 + } 127 + 128 + const available = await ctx.db 129 + .select({ 130 + themeUri: themePolicyAvailableThemes.themeUri, 131 + themeCid: themePolicyAvailableThemes.themeCid, 132 + }) 133 + .from(themePolicyAvailableThemes) 134 + .where(eq(themePolicyAvailableThemes.policyId, policy.id)); 135 + 136 + return c.json({ 137 + defaultLightThemeUri: policy.defaultLightThemeUri, 138 + defaultDarkThemeUri: policy.defaultDarkThemeUri, 139 + allowUserChoice: policy.allowUserChoice, 140 + availableThemes: available.map((t) => ({ 141 + uri: t.themeUri, 142 + cid: t.themeCid, 143 + })), 144 + }); 145 + } catch (error) { 146 + return handleRouteError(c, error, "Failed to retrieve theme policy", { 147 + operation: "GET /api/theme-policy", 148 + logger: ctx.logger, 149 + }); 150 + } 151 + }); 152 + }