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): block cid:\"\" as invalid strongRef; add DB failure test for needsLookup

- Introduce isMissingCid predicate (typeof !== string || === "") applied to all
three sites: needsLookup check, unresolvedUris filter, resolvedThemes map.
Explicit cid:"" bypassed the previous typeof-only guard and would have been
written verbatim to the PDS as an invalid strongRef CID.
- Add test: cid:"" entry returns 400 (same as absent cid not found in DB)
- Add test: DB select failure during needsLookup returns 500

+55 -5
+47
apps/appview/src/routes/__tests__/admin.test.ts
··· 3291 3291 expect(mockPutRecord).not.toHaveBeenCalled(); 3292 3292 }); 3293 3293 3294 + it("returns 400 when availableThemes entry has cid: \"\" (empty string is not a valid CID)", async () => { 3295 + // Explicit cid: "" must be treated the same as absent cid — not a valid strongRef CID 3296 + const unknownUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lblunknown2`; 3297 + 3298 + const res = await app.request("/api/admin/theme-policy", { 3299 + method: "PUT", 3300 + headers: { "Content-Type": "application/json" }, 3301 + body: JSON.stringify({ 3302 + defaultLightThemeUri: unknownUri, 3303 + defaultDarkThemeUri: unknownUri, 3304 + allowUserChoice: true, 3305 + availableThemes: [{ uri: unknownUri, cid: "" }], // empty string cid, not in DB 3306 + }), 3307 + }); 3308 + 3309 + expect(res.status).toBe(400); 3310 + const body = await res.json(); 3311 + expect(body.error).toMatch(/unknown theme uri/i); 3312 + expect(mockPutRecord).not.toHaveBeenCalled(); 3313 + }); 3314 + 3315 + it("returns 500 when DB query fails during uri-only CID lookup", async () => { 3316 + // Force needsLookup = true by omitting cid, then fail the DB select 3317 + const unknownUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lblunknown3`; 3318 + const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 3319 + throw new Error("Database connection lost"); 3320 + }); 3321 + 3322 + const res = await app.request("/api/admin/theme-policy", { 3323 + method: "PUT", 3324 + headers: { "Content-Type": "application/json" }, 3325 + body: JSON.stringify({ 3326 + defaultLightThemeUri: unknownUri, 3327 + defaultDarkThemeUri: unknownUri, 3328 + allowUserChoice: true, 3329 + availableThemes: [{ uri: unknownUri }], // no cid → triggers needsLookup 3330 + }), 3331 + }); 3332 + 3333 + expect(res.status).toBe(500); 3334 + const body = await res.json(); 3335 + expect(body.error).toMatch(/failed to look up theme data/i); 3336 + expect(mockPutRecord).not.toHaveBeenCalled(); 3337 + 3338 + dbSelectSpy.mockRestore(); 3339 + }); 3340 + 3294 3341 it("uses provided cid when entry already includes one (no DB lookup needed)", async () => { 3295 3342 // The existing validBody has cid on each entry — should use those directly 3296 3343 await app.request("/api/admin/theme-policy", {
+8 -5
apps/appview/src/routes/admin.ts
··· 1409 1409 1410 1410 const resolvedAllowUserChoice = typeof allowUserChoice === "boolean" ? allowUserChoice : true; 1411 1411 1412 - // Build URI→CID map from DB for entries that don't supply a cid 1412 + // Build URI→CID map from DB for entries that don't supply a valid cid. 1413 + // Treat cid: "" the same as absent — empty string is not a valid strongRef CID. 1414 + const isMissingCid = (t: { cid?: string }) => 1415 + typeof t.cid !== "string" || t.cid === ""; 1413 1416 let uriToCid = new Map<string, string>(); 1414 - const needsLookup = typedAvailableThemes.some((t) => typeof t.cid !== "string"); 1417 + const needsLookup = typedAvailableThemes.some(isMissingCid); 1415 1418 if (needsLookup) { 1416 1419 try { 1417 1420 const allThemes = await ctx.db ··· 1434 1437 } 1435 1438 } 1436 1439 1437 - // Resolve CIDs: use provided cid if present, otherwise look up from DB. 1440 + // Resolve CIDs: use provided cid if non-empty, otherwise look up from DB. 1438 1441 // Reject URIs that can't be resolved — "" is not a valid strongRef CID. 1439 1442 const unresolvedUris = typedAvailableThemes 1440 - .filter((t) => typeof t.cid !== "string" && !uriToCid.has(t.uri)) 1443 + .filter((t) => isMissingCid(t) && !uriToCid.has(t.uri)) 1441 1444 .map((t) => t.uri); 1442 1445 1443 1446 if (unresolvedUris.length > 0) { ··· 1449 1452 1450 1453 const resolvedThemes = typedAvailableThemes.map((t) => ({ 1451 1454 uri: t.uri, 1452 - cid: typeof t.cid === "string" ? t.cid : uriToCid.get(t.uri)!, 1455 + cid: !isMissingCid(t) ? t.cid! : uriToCid.get(t.uri)!, 1453 1456 })); 1454 1457 1455 1458 const lightTheme = resolvedThemes.find((t) => t.uri === defaultLightThemeUri)!;