From 1c68607279b61e2ffa506fa9370a00081a881bcf Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Sat, 28 Feb 2026 13:11:35 +0100 Subject: [PATCH] fix(auth): use public API for profile sync instead of authenticated session The authenticated agent's OAuth scope doesn't include app.bsky.actor.getProfile, causing profile sync to fail on every login. Since getProfile is a public API, use an unauthenticated agent pointed at https://public.api.bsky.app instead. This removes the OAuth client dependency from profile sync entirely. --- src/app.ts | 4 +- src/services/profile-sync.ts | 38 ++++++++--------- tests/unit/services/profile-sync.test.ts | 52 ++++-------------------- 3 files changed, 26 insertions(+), 68 deletions(-) diff --git a/src/app.ts b/src/app.ts index 8c38c07..ed7d562 100644 --- a/src/app.ts +++ b/src/app.ts @@ -216,8 +216,8 @@ export async function buildApp(env: Env) { const handleResolver = createHandleResolver(cache, db, app.log) app.decorate('handleResolver', handleResolver) - // Profile sync (fetches AT Protocol profile from PDS at login) - const profileSync = createProfileSyncService(oauthClient, db, app.log) + // Profile sync (fetches AT Protocol profile from Bluesky public API at login) + const profileSync = createProfileSyncService(db, app.log) app.decorate('profileSync', profileSync) // PLC DID service + Setup service diff --git a/src/services/profile-sync.ts b/src/services/profile-sync.ts index 3a363f3..5c93591 100644 --- a/src/services/profile-sync.ts +++ b/src/services/profile-sync.ts @@ -1,6 +1,5 @@ import { Agent } from '@atproto/api' import { eq } from 'drizzle-orm' -import type { NodeOAuthClient } from '@atproto/oauth-client-node' import type { Logger } from '../lib/logger.js' import type { Database } from '../db/index.js' import { users } from '../db/schema/users.js' @@ -9,7 +8,7 @@ import { users } from '../db/schema/users.js' // Types // --------------------------------------------------------------------------- -/** Profile data extracted from the user's PDS. */ +/** Profile data fetched from the Bluesky public API. */ export interface ProfileData { displayName: string | null avatarUrl: string | null @@ -30,9 +29,12 @@ const NULL_PROFILE: ProfileData = { } // --------------------------------------------------------------------------- -// Agent factory (injectable for testing) +// Public API agent factory (injectable for testing) // --------------------------------------------------------------------------- +/** Bluesky public AppView API -- no auth required for profile reads. */ +const BSKY_PUBLIC_API = 'https://public.api.bsky.app' + interface AgentLike { getProfile(params: { actor: string }): Promise<{ data: { @@ -45,12 +47,12 @@ interface AgentLike { } interface AgentFactory { - createAgent(session: unknown): AgentLike + createAgent(): AgentLike } const defaultAgentFactory: AgentFactory = { - createAgent(session: unknown): AgentLike { - return new Agent(session as ConstructorParameters[0]) + createAgent(): AgentLike { + return new Agent(new URL(BSKY_PUBLIC_API)) }, } @@ -60,34 +62,26 @@ const defaultAgentFactory: AgentFactory = { /** * Create a profile sync service that fetches a user's AT Protocol profile - * from their PDS at login time and updates the local users table. + * via the Bluesky public API at login time and updates the local users table. + * + * Uses the public AppView API (no auth required) so profile sync works + * regardless of which OAuth scopes the user granted. * - * @param oauthClient - AT Protocol OAuth client for session restore * @param db - Drizzle database instance * @param logger - Pino logger * @param agentFactory - Optional factory for creating Agent instances (testing) */ export function createProfileSyncService( - oauthClient: NodeOAuthClient, db: Database, logger: Logger, agentFactory: AgentFactory = defaultAgentFactory ): ProfileSyncService { return { async syncProfile(did: string): Promise { - // 1. Restore OAuth session and create agent - let agent: AgentLike - try { - const session = await oauthClient.restore(did) - agent = agentFactory.createAgent(session) - } catch (err: unknown) { - logger.debug({ did, err }, 'profile sync failed: could not restore OAuth session') - return NULL_PROFILE - } - - // 2. Fetch profile from PDS + // 1. Fetch profile from Bluesky public API (no auth needed) let profileData: ProfileData try { + const agent = agentFactory.createAgent() const response = await agent.getProfile({ actor: did }) profileData = { displayName: response.data.displayName ?? null, @@ -96,11 +90,11 @@ export function createProfileSyncService( bio: response.data.description ?? null, } } catch (err: unknown) { - logger.debug({ did, err }, 'profile sync failed: could not fetch profile from PDS') + logger.debug({ did, err }, 'profile sync failed: could not fetch profile from public API') return NULL_PROFILE } - // 3. Best-effort DB update + // 2. Best-effort DB update try { await db .update(users) diff --git a/tests/unit/services/profile-sync.test.ts b/tests/unit/services/profile-sync.test.ts index 6508ef3..9a36c78 100644 --- a/tests/unit/services/profile-sync.test.ts +++ b/tests/unit/services/profile-sync.test.ts @@ -3,7 +3,6 @@ import { createProfileSyncService } from '../../../src/services/profile-sync.js' import type { ProfileSyncService } from '../../../src/services/profile-sync.js' import type { Logger } from '../../../src/lib/logger.js' import type { Database } from '../../../src/db/index.js' -import type { NodeOAuthClient } from '@atproto/oauth-client-node' // --------------------------------------------------------------------------- // Mock logger @@ -27,12 +26,6 @@ function createMockLogger(): Logger { // Mock helpers // --------------------------------------------------------------------------- -function createMockOAuthClient(overrides?: { restore?: ReturnType }) { - return { - restore: overrides?.restore ?? vi.fn(), - } as unknown as NodeOAuthClient -} - function createMockDb(overrides?: { whereReturn?: ReturnType }) { const whereFn = overrides?.whereReturn ?? vi.fn().mockResolvedValue(undefined) const setFn = vi.fn().mockReturnValue({ where: whereFn }) @@ -83,21 +76,15 @@ const MOCK_MINIMAL_PROFILE_RESPONSE = { describe('ProfileSyncService', () => { let service: ProfileSyncService let mockLogger: Logger - let mockOAuthClient: NodeOAuthClient let mockDb: ReturnType let mockGetProfile: ReturnType - let mockRestore: ReturnType beforeEach(() => { mockLogger = createMockLogger() mockGetProfile = vi.fn().mockResolvedValue(MOCK_PROFILE_RESPONSE) - - // Mock session object that Agent constructor can use - mockRestore = vi.fn().mockResolvedValue({}) - mockOAuthClient = createMockOAuthClient({ restore: mockRestore }) mockDb = createMockDb() - service = createProfileSyncService(mockOAuthClient, mockDb, mockLogger, { + service = createProfileSyncService(mockDb, mockLogger, { createAgent: () => ({ getProfile: mockGetProfile, }), @@ -121,12 +108,6 @@ describe('ProfileSyncService', () => { }) }) - it('restores OAuth session for the given DID', async () => { - await service.syncProfile(TEST_DID) - - expect(mockRestore).toHaveBeenCalledWith(TEST_DID) - }) - it('calls getProfile with the user DID', async () => { await service.syncProfile(TEST_DID) @@ -157,11 +138,11 @@ describe('ProfileSyncService', () => { }) // ------------------------------------------------------------------------- - // OAuth restore failure + // getProfile failure // ------------------------------------------------------------------------- - it('returns null values when OAuth session restore fails', async () => { - mockRestore.mockRejectedValue(new Error('No stored session')) + it('returns null values when getProfile throws', async () => { + mockGetProfile.mockRejectedValue(new Error('Network timeout')) const result = await service.syncProfile(TEST_DID) @@ -173,8 +154,8 @@ describe('ProfileSyncService', () => { }) }) - it('logs at debug level when OAuth session restore fails', async () => { - mockRestore.mockRejectedValue(new Error('No stored session')) + it('logs at debug level when getProfile fails', async () => { + mockGetProfile.mockRejectedValue(new Error('Network timeout')) await service.syncProfile(TEST_DID) @@ -185,23 +166,6 @@ describe('ProfileSyncService', () => { ) }) - // ------------------------------------------------------------------------- - // getProfile failure - // ------------------------------------------------------------------------- - - it('returns null values when getProfile throws', async () => { - mockGetProfile.mockRejectedValue(new Error('Network timeout')) - - const result = await service.syncProfile(TEST_DID) - - expect(result).toStrictEqual({ - displayName: null, - avatarUrl: null, - bannerUrl: null, - bio: null, - }) - }) - // ------------------------------------------------------------------------- // DB update failure // ------------------------------------------------------------------------- @@ -211,7 +175,7 @@ describe('ProfileSyncService', () => { whereReturn: vi.fn().mockRejectedValue(new Error('DB connection lost')), }) - service = createProfileSyncService(mockOAuthClient, mockDb, mockLogger, { + service = createProfileSyncService(mockDb, mockLogger, { createAgent: () => ({ getProfile: mockGetProfile, }), @@ -234,7 +198,7 @@ describe('ProfileSyncService', () => { whereReturn: vi.fn().mockRejectedValue(new Error('DB connection lost')), }) - service = createProfileSyncService(mockOAuthClient, mockDb, mockLogger, { + service = createProfileSyncService(mockDb, mockLogger, { createAgent: () => ({ getProfile: mockGetProfile, }), -- 2.51.2