From 65a095669c09bf27a08dd32063d09a2af20d74d5 Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Sat, 28 Feb 2026 14:44:42 +0100 Subject: [PATCH] fix(auth): include displayName and avatarUrl in session responses (#102) The /api/auth/refresh and /api/auth/me endpoints only returned did, handle, and crossPostScopesGranted. The frontend AuthSession type expects displayName and avatarUrl for rendering the user avatar in the header bar. Now both endpoints query the users table and include these fields, falling back to null when no profile data exists yet. --- src/routes/auth.ts | 16 +++++++++ tests/unit/routes/auth.test.ts | 66 ++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 906deff..5f57fab 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -261,6 +261,12 @@ export function authRoutes(oauthClient: NodeOAuthClient): FastifyPluginCallback maxAge: sessionTtl, }) + // Fetch profile data (displayName, avatarUrl) from users table + const userRows = await app.db + .select({ displayName: users.displayName, avatarUrl: users.avatarUrl }) + .from(users) + .where(eq(users.did, session.did)) + // Query cross-post scope status from user preferences const prefRows = await app.db .select({ crossPostScopesGranted: userPreferences.crossPostScopesGranted }) @@ -272,6 +278,8 @@ export function authRoutes(oauthClient: NodeOAuthClient): FastifyPluginCallback expiresAt: session.accessTokenExpiresAt, did: session.did, handle: session.handle, + displayName: userRows[0]?.displayName ?? null, + avatarUrl: userRows[0]?.avatarUrl ?? null, crossPostScopesGranted: prefRows[0]?.crossPostScopesGranted ?? false, }) } catch (err: unknown) { @@ -321,6 +329,12 @@ export function authRoutes(oauthClient: NodeOAuthClient): FastifyPluginCallback return await reply.status(401).send({ error: 'Invalid or expired token' }) } + // Fetch profile data (displayName, avatarUrl) from users table + const meUserRows = await app.db + .select({ displayName: users.displayName, avatarUrl: users.avatarUrl }) + .from(users) + .where(eq(users.did, session.did)) + // Query cross-post scope status from user preferences const mePrefRows = await app.db .select({ crossPostScopesGranted: userPreferences.crossPostScopesGranted }) @@ -330,6 +344,8 @@ export function authRoutes(oauthClient: NodeOAuthClient): FastifyPluginCallback return await reply.status(200).send({ did: session.did, handle: session.handle, + displayName: meUserRows[0]?.displayName ?? null, + avatarUrl: meUserRows[0]?.avatarUrl ?? null, crossPostScopesGranted: mePrefRows[0]?.crossPostScopesGranted ?? false, }) } catch (err: unknown) { diff --git a/tests/unit/routes/auth.test.ts b/tests/unit/routes/auth.test.ts index 55f87ac..e8ff0ff 100644 --- a/tests/unit/routes/auth.test.ts +++ b/tests/unit/routes/auth.test.ts @@ -386,6 +386,44 @@ describe('auth routes', () => { expect(refreshCookie?.value).toBe(TEST_SID) }) + it('includes displayName and avatarUrl from users table', async () => { + const mockSession = makeMockSessionWithToken() + refreshSessionFn.mockResolvedValueOnce(mockSession) + // First select: users table → profile data + dbWhereFn.mockResolvedValueOnce([ + { displayName: 'Alice Wonderland', avatarUrl: 'https://cdn.bsky.app/avatar.jpg' }, + ]) + // Second select: userPreferences table + dbWhereFn.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/refresh', + cookies: { barazo_refresh: TEST_SID }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ displayName: string | null; avatarUrl: string | null }>() + expect(body.displayName).toBe('Alice Wonderland') + expect(body.avatarUrl).toBe('https://cdn.bsky.app/avatar.jpg') + }) + + it('returns null displayName and avatarUrl when user row not found', async () => { + const mockSession = makeMockSessionWithToken() + refreshSessionFn.mockResolvedValueOnce(mockSession) + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/refresh', + cookies: { barazo_refresh: TEST_SID }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ displayName: string | null; avatarUrl: string | null }>() + expect(body.displayName).toBeNull() + expect(body.avatarUrl).toBeNull() + }) + it('returns 401 when no cookie', async () => { const response = await app.inject({ method: 'POST', @@ -481,6 +519,28 @@ describe('auth routes', () => { expect(validateAccessTokenFn).toHaveBeenCalledWith(TEST_ACCESS_TOKEN) }) + it('includes displayName and avatarUrl from users table', async () => { + const mockSession = makeMockSession() + validateAccessTokenFn.mockResolvedValueOnce(mockSession) + // First select: users table → profile data + dbWhereFn.mockResolvedValueOnce([ + { displayName: 'Alice Wonderland', avatarUrl: 'https://cdn.bsky.app/avatar.jpg' }, + ]) + // Second select: userPreferences table + dbWhereFn.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'GET', + url: '/api/auth/me', + headers: { authorization: `Bearer ${TEST_ACCESS_TOKEN}` }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ displayName: string | null; avatarUrl: string | null }>() + expect(body.displayName).toBe('Alice Wonderland') + expect(body.avatarUrl).toBe('https://cdn.bsky.app/avatar.jpg') + }) + it('returns 401 for missing Authorization header', async () => { const response = await app.inject({ method: 'GET', @@ -658,6 +718,9 @@ describe('auth routes', () => { it('/me returns crossPostScopesGranted from user preferences', async () => { const mockSession = makeMockSession() validateAccessTokenFn.mockResolvedValueOnce(mockSession) + // First select: users table (profile data) + dbWhereFn.mockResolvedValueOnce([]) + // Second select: userPreferences table dbWhereFn.mockResolvedValueOnce([{ crossPostScopesGranted: true }]) const response = await app.inject({ @@ -690,6 +753,9 @@ describe('auth routes', () => { it('/refresh returns crossPostScopesGranted', async () => { const mockSession = makeMockSessionWithToken() refreshSessionFn.mockResolvedValueOnce(mockSession) + // First select: users table (profile data) + dbWhereFn.mockResolvedValueOnce([]) + // Second select: userPreferences table dbWhereFn.mockResolvedValueOnce([{ crossPostScopesGranted: true }]) const response = await app.inject({ -- 2.51.2