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.

fix: guard res.json() calls against SyntaxError from malformed AppView responses

The split-try-catch refactor (previous commit) isolated fetch() correctly but
left res.json() and permRes.json() unprotected. A proxy returning an HTML error
page on a 200 response would throw an unhandled SyntaxError, crashing the request
with no structured log at the failure site.

Wrap both .json() calls in their own try-catch blocks with specific error messages,
returning { authenticated: false } / empty permissions as appropriate.

Also: rename misleading test ("response is malformed" now clarifies it tests
missing fields, not SyntaxError), tighten TypeError assertion in
getSessionWithPermissions to verify did and error fields are logged.

+71 -3
+47 -1
apps/web/src/lib/__tests__/session.test.ts
··· 131 131 expect(logger.error).not.toHaveBeenCalled(); 132 132 }); 133 133 134 - it("returns unauthenticated when AppView response is malformed", async () => { 134 + it("returns unauthenticated when AppView response has missing fields", async () => { 135 135 mockFetch.mockResolvedValueOnce({ 136 136 ok: true, 137 137 json: () => ··· 147 147 ); 148 148 149 149 expect(result).toEqual({ authenticated: false }); 150 + }); 151 + 152 + it("returns unauthenticated when AppView returns invalid JSON", async () => { 153 + // A proxy or misconfigured server might return an HTML error page on a 200 response. 154 + // res.json() throws SyntaxError in that case — must be caught gracefully. 155 + mockFetch.mockResolvedValueOnce({ 156 + ok: true, 157 + json: () => 158 + Promise.reject( 159 + new SyntaxError("Unexpected token '<', \"<html>\" is not valid JSON") 160 + ), 161 + }); 162 + 163 + const result = await getSession( 164 + "http://localhost:3000", 165 + "atbb_session=token" 166 + ); 167 + 168 + expect(result).toEqual({ authenticated: false }); 169 + expect(logger.error).toHaveBeenCalledWith( 170 + expect.stringContaining("invalid JSON"), 171 + expect.any(Object) 172 + ); 150 173 }); 151 174 152 175 it("returns unauthenticated and logs when AppView is unreachable (network error)", async () => { ··· 284 307 expect(result.permissions.size).toBe(0); 285 308 expect(logger.error).toHaveBeenCalledWith( 286 309 expect.stringContaining("network error"), 310 + expect.objectContaining({ error: "fetch failed", did: "did:plc:abc" }) 311 + ); 312 + }); 313 + 314 + it("returns empty permissions when members/me returns invalid JSON", async () => { 315 + // A proxy might return an HTML error page even on 200. permRes.json() would throw SyntaxError. 316 + mockFetch.mockResolvedValueOnce({ 317 + ok: true, 318 + json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), 319 + }); 320 + mockFetch.mockResolvedValueOnce({ 321 + ok: true, 322 + json: () => 323 + Promise.reject( 324 + new SyntaxError("Unexpected token '<', \"<html>\" is not valid JSON") 325 + ), 326 + }); 327 + 328 + const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); 329 + expect(result.authenticated).toBe(true); 330 + expect(result.permissions.size).toBe(0); 331 + expect(logger.error).toHaveBeenCalledWith( 332 + expect.stringContaining("invalid JSON"), 287 333 expect.any(Object) 288 334 ); 289 335 });
+24 -2
apps/web/src/lib/session.ts
··· 45 45 return { authenticated: false }; 46 46 } 47 47 48 - const data = (await res.json()) as Record<string, unknown>; 48 + let data: Record<string, unknown>; 49 + try { 50 + data = (await res.json()) as Record<string, unknown>; 51 + } catch { 52 + logger.error("getSession: AppView returned invalid JSON — treating as unauthenticated", { 53 + operation: "GET /api/auth/session", 54 + status: res.status, 55 + }); 56 + return { authenticated: false }; 57 + } 49 58 50 59 if ( 51 60 data.authenticated === true && ··· 102 111 } 103 112 104 113 if (permRes.ok) { 105 - const data = (await permRes.json()) as Record<string, unknown>; 114 + let data: Record<string, unknown>; 115 + try { 116 + data = (await permRes.json()) as Record<string, unknown>; 117 + } catch { 118 + logger.error( 119 + "getSessionWithPermissions: members/me returned invalid JSON — continuing with empty permissions", 120 + { 121 + operation: "GET /api/admin/members/me", 122 + did: session.did, 123 + status: permRes.status, 124 + } 125 + ); 126 + return { ...session, permissions }; 127 + } 106 128 if (Array.isArray(data.permissions)) { 107 129 permissions = new Set(data.permissions as string[]); 108 130 }