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

feat(web): implement resolveTheme waterfall with CID integrity check (ATB-53)

+248 -1
+154 -1
apps/web/src/lib/__tests__/theme-resolution.test.ts
··· 1 - import { describe, it, expect } from "vitest"; 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 2 import { 3 3 detectColorScheme, 4 4 parseRkeyFromUri, 5 5 FALLBACK_THEME, 6 + resolveTheme, 6 7 } from "../theme-resolution.js"; 8 + import { logger } from "../logger.js"; 9 + 10 + vi.mock("../logger.js", () => ({ 11 + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() }, 12 + })); 7 13 8 14 describe("detectColorScheme", () => { 9 15 it("returns 'light' by default when no cookie or hint", () => { ··· 71 77 expect(FALLBACK_THEME.cssOverrides).toBeNull(); 72 78 }); 73 79 }); 80 + 81 + describe("resolveTheme", () => { 82 + const mockFetch = vi.fn(); 83 + const APPVIEW = "http://localhost:3001"; 84 + 85 + beforeEach(() => { 86 + vi.stubGlobal("fetch", mockFetch); 87 + vi.mocked(logger.warn).mockClear(); 88 + vi.mocked(logger.error).mockClear(); 89 + }); 90 + 91 + afterEach(() => { 92 + vi.unstubAllGlobals(); 93 + mockFetch.mockReset(); 94 + }); 95 + 96 + function policyResponse(overrides: object = {}) { 97 + return { 98 + ok: true, 99 + json: () => 100 + Promise.resolve({ 101 + defaultLightThemeUri: 102 + "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 103 + defaultDarkThemeUri: 104 + "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 105 + allowUserChoice: true, 106 + availableThemes: [ 107 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafylight" }, 108 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" }, 109 + ], 110 + ...overrides, 111 + }), 112 + }; 113 + } 114 + 115 + function themeResponse(colorScheme: "light" | "dark", cid: string) { 116 + return { 117 + ok: true, 118 + json: () => 119 + Promise.resolve({ 120 + cid, 121 + tokens: { "color-bg": colorScheme === "light" ? "#fff" : "#111" }, 122 + cssOverrides: null, 123 + fontUrls: null, 124 + colorScheme, 125 + }), 126 + }; 127 + } 128 + 129 + it("returns FALLBACK_THEME with detected colorScheme when policy fetch fails (non-ok)", async () => { 130 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); 131 + const result = await resolveTheme(APPVIEW, undefined, undefined); 132 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 133 + expect(result.colorScheme).toBe("light"); 134 + }); 135 + 136 + it("returns FALLBACK_THEME with dark colorScheme when policy fails and dark cookie set", async () => { 137 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); 138 + const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); 139 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 140 + expect(result.colorScheme).toBe("dark"); 141 + }); 142 + 143 + it("returns FALLBACK_THEME when policy has no defaultLightThemeUri", async () => { 144 + mockFetch.mockResolvedValueOnce(policyResponse({ defaultLightThemeUri: null })); 145 + const result = await resolveTheme(APPVIEW, undefined, undefined); 146 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 147 + }); 148 + 149 + it("returns FALLBACK_THEME when theme fetch fails", async () => { 150 + mockFetch 151 + .mockResolvedValueOnce(policyResponse()) 152 + .mockResolvedValueOnce({ ok: false, status: 404 }); 153 + const result = await resolveTheme(APPVIEW, undefined, undefined); 154 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 155 + }); 156 + 157 + it("returns FALLBACK_THEME and logs warning on CID mismatch", async () => { 158 + mockFetch 159 + .mockResolvedValueOnce(policyResponse()) 160 + .mockResolvedValueOnce(themeResponse("light", "WRONG_CID")); 161 + const result = await resolveTheme(APPVIEW, undefined, undefined); 162 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 163 + expect(logger.warn).toHaveBeenCalledWith( 164 + expect.stringContaining("CID mismatch"), 165 + expect.objectContaining({ expectedCid: "bafylight", actualCid: "WRONG_CID" }) 166 + ); 167 + }); 168 + 169 + it("resolves the light theme on happy path (no cookie)", async () => { 170 + mockFetch 171 + .mockResolvedValueOnce(policyResponse()) 172 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 173 + const result = await resolveTheme(APPVIEW, undefined, undefined); 174 + expect(result.tokens["color-bg"]).toBe("#fff"); 175 + expect(result.colorScheme).toBe("light"); 176 + expect(result.cssOverrides).toBeNull(); 177 + expect(result.fontUrls).toBeNull(); 178 + }); 179 + 180 + it("resolves the dark theme when atbb-color-scheme=dark cookie is set", async () => { 181 + mockFetch 182 + .mockResolvedValueOnce(policyResponse()) 183 + .mockResolvedValueOnce(themeResponse("dark", "bafydark")); 184 + const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); 185 + expect(result.tokens["color-bg"]).toBe("#111"); 186 + expect(result.colorScheme).toBe("dark"); 187 + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbldark")); 188 + }); 189 + 190 + it("resolves dark theme from Sec-CH-Prefers-Color-Scheme hint when no cookie", async () => { 191 + mockFetch 192 + .mockResolvedValueOnce(policyResponse()) 193 + .mockResolvedValueOnce(themeResponse("dark", "bafydark")); 194 + const result = await resolveTheme(APPVIEW, undefined, "dark"); 195 + expect(result.colorScheme).toBe("dark"); 196 + }); 197 + 198 + it("returns FALLBACK_THEME and logs error on network exception", async () => { 199 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 200 + const result = await resolveTheme(APPVIEW, undefined, undefined); 201 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 202 + expect(logger.error).toHaveBeenCalledWith( 203 + expect.stringContaining("Theme resolution failed"), 204 + expect.objectContaining({ operation: "resolveTheme" }) 205 + ); 206 + }); 207 + 208 + it("passes cssOverrides and fontUrls through from theme response", async () => { 209 + mockFetch 210 + .mockResolvedValueOnce(policyResponse()) 211 + .mockResolvedValueOnce({ 212 + ok: true, 213 + json: () => 214 + Promise.resolve({ 215 + cid: "bafylight", 216 + tokens: { "color-bg": "#fff" }, 217 + cssOverrides: ".btn { font-weight: 700; }", 218 + fontUrls: ["https://fonts.example.com/font.css"], 219 + colorScheme: "light", 220 + }), 221 + }); 222 + const result = await resolveTheme(APPVIEW, undefined, undefined); 223 + expect(result.cssOverrides).toBe(".btn { font-weight: 700; }"); 224 + expect(result.fontUrls).toEqual(["https://fonts.example.com/font.css"]); 225 + }); 226 + });
+94
apps/web/src/lib/theme-resolution.ts
··· 1 1 import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" }; 2 + import { logger } from "./logger.js"; 2 3 3 4 export type ResolvedTheme = { 4 5 tokens: Record<string, string>; ··· 47 48 if (parts.length < 5) return null; 48 49 return parts[4] ?? null; 49 50 } 51 + 52 + interface ThemePolicyResponse { 53 + defaultLightThemeUri: string | null; 54 + defaultDarkThemeUri: string | null; 55 + allowUserChoice: boolean; 56 + availableThemes: Array<{ uri: string; cid: string }>; 57 + } 58 + 59 + interface ThemeResponse { 60 + cid: string; 61 + tokens: Record<string, unknown>; 62 + cssOverrides: string | null; 63 + fontUrls: string[] | null; 64 + } 65 + 66 + /** 67 + * Resolves which theme to render for a request using the waterfall: 68 + * 1. User preference — not yet implemented (TODO: Theme Phase 4) 69 + * 2. Color scheme default — atbb-color-scheme cookie or Sec-CH hint 70 + * 3. Forum default — fetched from GET /api/theme-policy 71 + * 4. Hardcoded fallback — FALLBACK_THEME (neobrutal-light) 72 + * 73 + * Never throws — always returns a usable theme. 74 + */ 75 + export async function resolveTheme( 76 + appviewUrl: string, 77 + cookieHeader: string | undefined, 78 + colorSchemeHint: string | undefined 79 + ): Promise<ResolvedTheme> { 80 + const colorScheme = detectColorScheme(cookieHeader, colorSchemeHint); 81 + 82 + // Step 1: User preference 83 + // TODO: implement when space.atbb.membership.preferredTheme is added (Theme Phase 4) 84 + 85 + // Steps 2-3: Forum default via theme policy 86 + try { 87 + const policyRes = await fetch(`${appviewUrl}/api/theme-policy`); 88 + if (!policyRes.ok) { 89 + return { ...FALLBACK_THEME, colorScheme }; 90 + } 91 + 92 + const policy = (await policyRes.json()) as ThemePolicyResponse; 93 + 94 + const defaultUri = 95 + colorScheme === "dark" 96 + ? policy.defaultDarkThemeUri 97 + : policy.defaultLightThemeUri; 98 + 99 + if (!defaultUri) { 100 + return { ...FALLBACK_THEME, colorScheme }; 101 + } 102 + 103 + const rkey = parseRkeyFromUri(defaultUri); 104 + if (!rkey) { 105 + return { ...FALLBACK_THEME, colorScheme }; 106 + } 107 + 108 + const expectedCid = 109 + policy.availableThemes.find((t) => t.uri === defaultUri)?.cid ?? null; 110 + 111 + const themeRes = await fetch(`${appviewUrl}/api/themes/${rkey}`); 112 + if (!themeRes.ok) { 113 + return { ...FALLBACK_THEME, colorScheme }; 114 + } 115 + 116 + const theme = (await themeRes.json()) as ThemeResponse; 117 + 118 + if (expectedCid && theme.cid !== expectedCid) { 119 + logger.warn("Theme CID mismatch — using hardcoded fallback", { 120 + operation: "resolveTheme", 121 + expectedCid, 122 + actualCid: theme.cid, 123 + themeUri: defaultUri, 124 + }); 125 + return { ...FALLBACK_THEME, colorScheme }; 126 + } 127 + 128 + return { 129 + tokens: theme.tokens as Record<string, string>, 130 + cssOverrides: theme.cssOverrides ?? null, 131 + fontUrls: theme.fontUrls ?? null, 132 + colorScheme, 133 + }; 134 + } catch (error) { 135 + // Intentionally don't re-throw: a broken theme system should serve the 136 + // fallback and log the error, rather than crash every page request. 137 + logger.error("Theme resolution failed — using hardcoded fallback", { 138 + operation: "resolveTheme", 139 + error: error instanceof Error ? error.message : String(error), 140 + }); 141 + return { ...FALLBACK_THEME, colorScheme }; 142 + } 143 + }