diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts new file mode 100644 index 0000000..9f83b73 --- /dev/null +++ b/src/lib/api-client.ts @@ -0,0 +1,143 @@ +import { AtpAgent } from '@atproto/api'; +import type { AtpSessionData } from '@atproto/api'; +import { + clearCurrentSessionMetadata, + deleteSession, + getCurrentSessionMetadata, + loadSession, + saveCurrentSessionMetadata, + saveSession, +} from './session.js'; + +/** + * API client wrapper for AT Protocol operations + * Integrates with session management for persistent authentication + */ +export class TangledApiClient { + private agent: AtpAgent; + + constructor(serviceUrl = 'https://bsky.social') { + this.agent = new AtpAgent({ service: serviceUrl }); + } + + /** + * Login with identifier (handle or DID) and password + * Supports custom domain handles (e.g., "markbennett.ca") + * + * @param identifier - User's handle or DID + * @param password - App password + */ + async login(identifier: string, password: string): Promise { + try { + const response = await this.agent.login({ identifier, password }); + + if (!response.success || !response.data) { + throw new Error('Login failed: No session data received'); + } + + // Ensure all required fields are present + const sessionData: AtpSessionData = { + ...response.data, + active: response.data.active ?? true, + }; + + // Save session to keychain + await saveSession(sessionData); + + // Save metadata for current session tracking + await saveCurrentSessionMetadata({ + handle: sessionData.handle, + did: sessionData.did, + pds: this.agent.service.toString(), + lastUsed: new Date().toISOString(), + }); + + return sessionData; + } catch (error) { + throw new Error(`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Logout and clear session data + */ + async logout(): Promise { + const metadata = await getCurrentSessionMetadata(); + + if (!metadata) { + throw new Error('No active session found'); + } + + // Delete session from keychain + await deleteSession(metadata.did); + + // Clear current session metadata + await clearCurrentSessionMetadata(); + } + + /** + * Resume session from stored credentials + * Returns true if session was successfully resumed + */ + async resumeSession(): Promise { + try { + const metadata = await getCurrentSessionMetadata(); + + if (!metadata) { + return false; + } + + const sessionData = await loadSession(metadata.did); + + if (!sessionData) { + // Metadata exists but session data is missing - clean up + await clearCurrentSessionMetadata(); + return false; + } + + // Resume session with agent + await this.agent.resumeSession(sessionData); + + // Update last used timestamp + await saveCurrentSessionMetadata({ + ...metadata, + lastUsed: new Date().toISOString(), + }); + + return true; + } catch (error) { + // If resume fails, clear invalid session + await clearCurrentSessionMetadata(); + return false; + } + } + + /** + * Check if user is currently authenticated + */ + isAuthenticated(): boolean { + return !!this.agent.session; + } + + /** + * Get the underlying AtpAgent instance + * Use this for direct API calls + */ + getAgent(): AtpAgent { + return this.agent; + } + + /** + * Get current session data + */ + getSession(): AtpSessionData | undefined { + return this.agent.session; + } +} + +/** + * Create a new API client instance + */ +export function createApiClient(serviceUrl?: string): TangledApiClient { + return new TangledApiClient(serviceUrl); +} diff --git a/tests/lib/api-client.test.ts b/tests/lib/api-client.test.ts new file mode 100644 index 0000000..1d5c57e --- /dev/null +++ b/tests/lib/api-client.test.ts @@ -0,0 +1,173 @@ +import type { AtpSessionData } from '@atproto/api'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TangledApiClient } from '../../src/lib/api-client.js'; +import * as sessionModule from '../../src/lib/session.js'; +import { mockSessionData, mockSessionMetadata } from '../helpers/mock-data.js'; + +// Mock @atproto/api +vi.mock('@atproto/api', () => { + return { + AtpAgent: vi.fn().mockImplementation(() => { + let currentSession: AtpSessionData | undefined = undefined; + + return { + service: { toString: () => 'https://bsky.social' }, + get session() { + return currentSession; + }, + login: vi.fn().mockImplementation(async () => { + currentSession = mockSessionData; + return { + success: true, + data: mockSessionData, + }; + }), + resumeSession: vi.fn().mockImplementation(async (session) => { + currentSession = session; + }), + }; + }), + }; +}); + +// Mock session management +vi.mock('../../src/lib/session.js', () => ({ + saveSession: vi.fn(), + loadSession: vi.fn(), + deleteSession: vi.fn(), + saveCurrentSessionMetadata: vi.fn(), + getCurrentSessionMetadata: vi.fn(), + clearCurrentSessionMetadata: vi.fn(), +})); + +describe('TangledApiClient', () => { + let client: TangledApiClient; + + beforeEach(() => { + vi.clearAllMocks(); + + // Reset mock implementations + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); + vi.mocked(sessionModule.loadSession).mockResolvedValue(null); + + client = new TangledApiClient(); + }); + + describe('login', () => { + it('should login successfully and save session', async () => { + const result = await client.login('user.bsky.social', 'password'); + + expect(result).toEqual(mockSessionData); + expect(vi.mocked(sessionModule.saveSession)).toHaveBeenCalledWith(mockSessionData); + expect(vi.mocked(sessionModule.saveCurrentSessionMetadata)).toHaveBeenCalledWith({ + handle: mockSessionData.handle, + did: mockSessionData.did, + pds: 'https://bsky.social', + lastUsed: expect.any(String), + }); + }); + + it('should support custom domain handles', async () => { + const result = await client.login('markbennett.ca', 'password'); + + expect(result).toEqual(mockSessionData); + expect(vi.mocked(sessionModule.saveSession)).toHaveBeenCalled(); + }); + + it('should throw error on login failure', async () => { + const agent = client.getAgent(); + vi.mocked(agent.login).mockResolvedValueOnce({ + success: false, + headers: {}, + data: undefined, + } as never); + + await expect(client.login('user.bsky.social', 'wrong')).rejects.toThrow( + 'Login failed: No session data received' + ); + }); + }); + + describe('logout', () => { + it('should logout and clear session', async () => { + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); + + await client.logout(); + + expect(vi.mocked(sessionModule.deleteSession)).toHaveBeenCalledWith(mockSessionMetadata.did); + expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled(); + }); + + it('should throw error if no active session', async () => { + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); + + await expect(client.logout()).rejects.toThrow('No active session found'); + }); + }); + + describe('resumeSession', () => { + it('should resume session from stored data', async () => { + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); + vi.mocked(sessionModule.loadSession).mockResolvedValue(mockSessionData); + + const resumed = await client.resumeSession(); + + expect(resumed).toBe(true); + expect(vi.mocked(sessionModule.loadSession)).toHaveBeenCalledWith(mockSessionMetadata.did); + expect(vi.mocked(sessionModule.saveCurrentSessionMetadata)).toHaveBeenCalledWith({ + ...mockSessionMetadata, + lastUsed: expect.any(String), + }); + }); + + it('should return false if no metadata exists', async () => { + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); + + const resumed = await client.resumeSession(); + + expect(resumed).toBe(false); + }); + + it('should return false and cleanup if session data is missing', async () => { + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); + vi.mocked(sessionModule.loadSession).mockResolvedValue(null); + + const resumed = await client.resumeSession(); + + expect(resumed).toBe(false); + expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled(); + }); + + it('should return false and cleanup on resume error', async () => { + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); + vi.mocked(sessionModule.loadSession).mockResolvedValue(mockSessionData); + + const agent = client.getAgent(); + vi.mocked(agent.resumeSession).mockRejectedValueOnce(new Error('Resume failed')); + + const resumed = await client.resumeSession(); + + expect(resumed).toBe(false); + expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled(); + }); + }); + + describe('isAuthenticated', () => { + it('should return false when not authenticated', () => { + expect(client.isAuthenticated()).toBe(false); + }); + }); + + describe('getAgent', () => { + it('should return the AtpAgent instance', () => { + const agent = client.getAgent(); + expect(agent).toBeDefined(); + }); + }); + + describe('getSession', () => { + it('should return undefined when not authenticated', () => { + expect(client.getSession()).toBeUndefined(); + }); + }); +});