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

fix(atb-58): address PR review — CID validation, SyntaxError handling, Bruno seq

- Return 400 (not 200 with cid:"") when availableThemes contains uri-only entries
not found in the themes DB — empty string is not a valid AT Proto strongRef CID
- Wrap Response.json() calls in GET /admin/themes in inner try-catch so upstream
non-JSON responses are caught as parse errors rather than re-thrown as programming
errors via isProgrammingError(SyntaxError)
- Fix Bruno seq conflict: Duplicate Theme seq 4→5, List Themes seq 5→6

+64 -13
+5 -6
apps/appview/src/routes/__tests__/admin.test.ts
··· 3270 3270 expect(putCall.record.defaultDarkTheme).toEqual({ theme: { uri: themeUri, cid: "bafytheme1" } }); 3271 3271 }); 3272 3272 3273 - it("accepts availableThemes with uri only — uses empty string cid when uri not found in DB", async () => { 3273 + it("returns 400 when availableThemes contains a uri-only entry not found in DB", async () => { 3274 3274 // No theme inserted in DB — URI won't be found 3275 3275 const unknownUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lblunknown1`; 3276 3276 ··· 3285 3285 }), 3286 3286 }); 3287 3287 3288 - expect(res.status).toBe(200); 3289 - expect(mockPutRecord).toHaveBeenCalledOnce(); 3290 - const putCall = mockPutRecord.mock.calls[0][0]; 3291 - // Falls back to empty string when URI not found in DB 3292 - expect(putCall.record.availableThemes[0]).toEqual({ theme: { uri: unknownUri, cid: "" } }); 3288 + expect(res.status).toBe(400); 3289 + const body = await res.json(); 3290 + expect(body.error).toMatch(/unknown theme uri/i); 3291 + expect(mockPutRecord).not.toHaveBeenCalled(); 3293 3292 }); 3294 3293 3295 3294 it("uses provided cid when entry already includes one (no DB lookup needed)", async () => {
+14 -2
apps/appview/src/routes/admin.ts
··· 1434 1434 } 1435 1435 } 1436 1436 1437 - // Resolve CIDs: use provided cid if present, otherwise look up from DB, fallback to "" 1437 + // Resolve CIDs: use provided cid if present, otherwise look up from DB. 1438 + // Reject URIs that can't be resolved — "" is not a valid strongRef CID. 1439 + const unresolvedUris = typedAvailableThemes 1440 + .filter((t) => typeof t.cid !== "string" && !uriToCid.has(t.uri)) 1441 + .map((t) => t.uri); 1442 + 1443 + if (unresolvedUris.length > 0) { 1444 + return c.json( 1445 + { error: `Unknown theme URIs in availableThemes: ${unresolvedUris.join(", ")}` }, 1446 + 400 1447 + ); 1448 + } 1449 + 1438 1450 const resolvedThemes = typedAvailableThemes.map((t) => ({ 1439 1451 uri: t.uri, 1440 - cid: typeof t.cid === "string" ? t.cid : (uriToCid.get(t.uri) ?? ""), 1452 + cid: typeof t.cid === "string" ? t.cid : uriToCid.get(t.uri)!, 1441 1453 })); 1442 1454 1443 1455 const lightTheme = resolvedThemes.find((t) => t.uri === defaultLightThemeUri)!;
+26
apps/web/src/routes/__tests__/admin.test.tsx
··· 2226 2226 expect(html).toContain("neobrutal-dark"); 2227 2227 expect(html).toContain("blank"); 2228 2228 }); 2229 + 2230 + it("renders page gracefully when AppView returns non-JSON response", async () => { 2231 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2232 + // AppView returns an HTML error page — .json() throws SyntaxError 2233 + mockFetch.mockResolvedValueOnce({ 2234 + ok: true, 2235 + status: 200, 2236 + statusText: "OK", 2237 + json: () => Promise.reject(new SyntaxError("Unexpected token '<' in JSON")), 2238 + }); 2239 + mockFetch.mockResolvedValueOnce({ 2240 + ok: true, 2241 + status: 200, 2242 + statusText: "OK", 2243 + json: () => Promise.reject(new SyntaxError("Unexpected token '<' in JSON")), 2244 + }); 2245 + 2246 + const routes = await loadAdminRoutes(); 2247 + const res = await routes.request("/admin/themes", { 2248 + headers: { cookie: "atbb_session=token" }, 2249 + }); 2250 + // Should render the page with empty data rather than crashing with 500 2251 + expect(res.status).toBe(200); 2252 + const html = await res.text(); 2253 + expect(html).toContain("No themes yet"); 2254 + }); 2229 2255 }); 2230 2256 2231 2257 describe("createAdminRoutes — POST /admin/themes", () => {
+17 -3
apps/web/src/routes/admin.tsx
··· 1520 1520 ]); 1521 1521 1522 1522 if (themesRes.ok) { 1523 - const data = (await themesRes.json()) as { themes: AdminThemeEntry[] }; 1524 - adminThemes = data.themes; 1523 + try { 1524 + const data = (await themesRes.json()) as { themes: AdminThemeEntry[] }; 1525 + adminThemes = data.themes; 1526 + } catch { 1527 + logger.error("Failed to parse admin themes response", { 1528 + operation: "GET /admin/themes", 1529 + status: themesRes.status, 1530 + }); 1531 + } 1525 1532 } else { 1526 1533 logger.error("Failed to fetch admin themes list", { 1527 1534 operation: "GET /admin/themes", ··· 1530 1537 } 1531 1538 1532 1539 if (policyRes.ok) { 1533 - policy = (await policyRes.json()) as ThemePolicy; 1540 + try { 1541 + policy = (await policyRes.json()) as ThemePolicy; 1542 + } catch { 1543 + logger.error("Failed to parse theme policy response", { 1544 + operation: "GET /admin/themes", 1545 + status: policyRes.status, 1546 + }); 1547 + } 1534 1548 } else if (policyRes.status !== 404) { 1535 1549 logger.error("Failed to fetch theme policy", { 1536 1550 operation: "GET /admin/themes",
+1 -1
bruno/AppView API/Admin Themes/Duplicate Theme.bru
··· 1 1 meta { 2 2 name: Duplicate Theme 3 3 type: http 4 - seq: 4 4 + seq: 5 5 5 } 6 6 7 7 post {
+1 -1
bruno/AppView API/Admin Themes/List Themes.bru
··· 1 1 meta { 2 2 name: List Themes 3 3 type: http 4 - seq: 5 4 + seq: 6 5 5 } 6 6 7 7 get {