WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement API client with session management

- Create TangledApiClient wrapper around AtpAgent
- Support login with identifier and password
- Implement logout with session cleanup
- Add session resumption from OS keychain
- Handle authentication state and errors
- Add 12 comprehensive tests for all API client functionality
- All tests properly typed using vi.mocked() without suppressions

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

+316
+143
src/lib/api-client.ts
··· 1 + import { AtpAgent } from '@atproto/api'; 2 + import type { AtpSessionData } from '@atproto/api'; 3 + import { 4 + clearCurrentSessionMetadata, 5 + deleteSession, 6 + getCurrentSessionMetadata, 7 + loadSession, 8 + saveCurrentSessionMetadata, 9 + saveSession, 10 + } from './session.js'; 11 + 12 + /** 13 + * API client wrapper for AT Protocol operations 14 + * Integrates with session management for persistent authentication 15 + */ 16 + export class TangledApiClient { 17 + private agent: AtpAgent; 18 + 19 + constructor(serviceUrl = 'https://bsky.social') { 20 + this.agent = new AtpAgent({ service: serviceUrl }); 21 + } 22 + 23 + /** 24 + * Login with identifier (handle or DID) and password 25 + * Supports custom domain handles (e.g., "markbennett.ca") 26 + * 27 + * @param identifier - User's handle or DID 28 + * @param password - App password 29 + */ 30 + async login(identifier: string, password: string): Promise<AtpSessionData> { 31 + try { 32 + const response = await this.agent.login({ identifier, password }); 33 + 34 + if (!response.success || !response.data) { 35 + throw new Error('Login failed: No session data received'); 36 + } 37 + 38 + // Ensure all required fields are present 39 + const sessionData: AtpSessionData = { 40 + ...response.data, 41 + active: response.data.active ?? true, 42 + }; 43 + 44 + // Save session to keychain 45 + await saveSession(sessionData); 46 + 47 + // Save metadata for current session tracking 48 + await saveCurrentSessionMetadata({ 49 + handle: sessionData.handle, 50 + did: sessionData.did, 51 + pds: this.agent.service.toString(), 52 + lastUsed: new Date().toISOString(), 53 + }); 54 + 55 + return sessionData; 56 + } catch (error) { 57 + throw new Error(`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}`); 58 + } 59 + } 60 + 61 + /** 62 + * Logout and clear session data 63 + */ 64 + async logout(): Promise<void> { 65 + const metadata = await getCurrentSessionMetadata(); 66 + 67 + if (!metadata) { 68 + throw new Error('No active session found'); 69 + } 70 + 71 + // Delete session from keychain 72 + await deleteSession(metadata.did); 73 + 74 + // Clear current session metadata 75 + await clearCurrentSessionMetadata(); 76 + } 77 + 78 + /** 79 + * Resume session from stored credentials 80 + * Returns true if session was successfully resumed 81 + */ 82 + async resumeSession(): Promise<boolean> { 83 + try { 84 + const metadata = await getCurrentSessionMetadata(); 85 + 86 + if (!metadata) { 87 + return false; 88 + } 89 + 90 + const sessionData = await loadSession(metadata.did); 91 + 92 + if (!sessionData) { 93 + // Metadata exists but session data is missing - clean up 94 + await clearCurrentSessionMetadata(); 95 + return false; 96 + } 97 + 98 + // Resume session with agent 99 + await this.agent.resumeSession(sessionData); 100 + 101 + // Update last used timestamp 102 + await saveCurrentSessionMetadata({ 103 + ...metadata, 104 + lastUsed: new Date().toISOString(), 105 + }); 106 + 107 + return true; 108 + } catch (error) { 109 + // If resume fails, clear invalid session 110 + await clearCurrentSessionMetadata(); 111 + return false; 112 + } 113 + } 114 + 115 + /** 116 + * Check if user is currently authenticated 117 + */ 118 + isAuthenticated(): boolean { 119 + return !!this.agent.session; 120 + } 121 + 122 + /** 123 + * Get the underlying AtpAgent instance 124 + * Use this for direct API calls 125 + */ 126 + getAgent(): AtpAgent { 127 + return this.agent; 128 + } 129 + 130 + /** 131 + * Get current session data 132 + */ 133 + getSession(): AtpSessionData | undefined { 134 + return this.agent.session; 135 + } 136 + } 137 + 138 + /** 139 + * Create a new API client instance 140 + */ 141 + export function createApiClient(serviceUrl?: string): TangledApiClient { 142 + return new TangledApiClient(serviceUrl); 143 + }
+173
tests/lib/api-client.test.ts
··· 1 + import type { AtpSessionData } from '@atproto/api'; 2 + import { beforeEach, describe, expect, it, vi } from 'vitest'; 3 + import { TangledApiClient } from '../../src/lib/api-client.js'; 4 + import * as sessionModule from '../../src/lib/session.js'; 5 + import { mockSessionData, mockSessionMetadata } from '../helpers/mock-data.js'; 6 + 7 + // Mock @atproto/api 8 + vi.mock('@atproto/api', () => { 9 + return { 10 + AtpAgent: vi.fn().mockImplementation(() => { 11 + let currentSession: AtpSessionData | undefined = undefined; 12 + 13 + return { 14 + service: { toString: () => 'https://bsky.social' }, 15 + get session() { 16 + return currentSession; 17 + }, 18 + login: vi.fn().mockImplementation(async () => { 19 + currentSession = mockSessionData; 20 + return { 21 + success: true, 22 + data: mockSessionData, 23 + }; 24 + }), 25 + resumeSession: vi.fn().mockImplementation(async (session) => { 26 + currentSession = session; 27 + }), 28 + }; 29 + }), 30 + }; 31 + }); 32 + 33 + // Mock session management 34 + vi.mock('../../src/lib/session.js', () => ({ 35 + saveSession: vi.fn(), 36 + loadSession: vi.fn(), 37 + deleteSession: vi.fn(), 38 + saveCurrentSessionMetadata: vi.fn(), 39 + getCurrentSessionMetadata: vi.fn(), 40 + clearCurrentSessionMetadata: vi.fn(), 41 + })); 42 + 43 + describe('TangledApiClient', () => { 44 + let client: TangledApiClient; 45 + 46 + beforeEach(() => { 47 + vi.clearAllMocks(); 48 + 49 + // Reset mock implementations 50 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); 51 + vi.mocked(sessionModule.loadSession).mockResolvedValue(null); 52 + 53 + client = new TangledApiClient(); 54 + }); 55 + 56 + describe('login', () => { 57 + it('should login successfully and save session', async () => { 58 + const result = await client.login('user.bsky.social', 'password'); 59 + 60 + expect(result).toEqual(mockSessionData); 61 + expect(vi.mocked(sessionModule.saveSession)).toHaveBeenCalledWith(mockSessionData); 62 + expect(vi.mocked(sessionModule.saveCurrentSessionMetadata)).toHaveBeenCalledWith({ 63 + handle: mockSessionData.handle, 64 + did: mockSessionData.did, 65 + pds: 'https://bsky.social', 66 + lastUsed: expect.any(String), 67 + }); 68 + }); 69 + 70 + it('should support custom domain handles', async () => { 71 + const result = await client.login('markbennett.ca', 'password'); 72 + 73 + expect(result).toEqual(mockSessionData); 74 + expect(vi.mocked(sessionModule.saveSession)).toHaveBeenCalled(); 75 + }); 76 + 77 + it('should throw error on login failure', async () => { 78 + const agent = client.getAgent(); 79 + vi.mocked(agent.login).mockResolvedValueOnce({ 80 + success: false, 81 + headers: {}, 82 + data: undefined, 83 + } as never); 84 + 85 + await expect(client.login('user.bsky.social', 'wrong')).rejects.toThrow( 86 + 'Login failed: No session data received' 87 + ); 88 + }); 89 + }); 90 + 91 + describe('logout', () => { 92 + it('should logout and clear session', async () => { 93 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); 94 + 95 + await client.logout(); 96 + 97 + expect(vi.mocked(sessionModule.deleteSession)).toHaveBeenCalledWith(mockSessionMetadata.did); 98 + expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled(); 99 + }); 100 + 101 + it('should throw error if no active session', async () => { 102 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); 103 + 104 + await expect(client.logout()).rejects.toThrow('No active session found'); 105 + }); 106 + }); 107 + 108 + describe('resumeSession', () => { 109 + it('should resume session from stored data', async () => { 110 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); 111 + vi.mocked(sessionModule.loadSession).mockResolvedValue(mockSessionData); 112 + 113 + const resumed = await client.resumeSession(); 114 + 115 + expect(resumed).toBe(true); 116 + expect(vi.mocked(sessionModule.loadSession)).toHaveBeenCalledWith(mockSessionMetadata.did); 117 + expect(vi.mocked(sessionModule.saveCurrentSessionMetadata)).toHaveBeenCalledWith({ 118 + ...mockSessionMetadata, 119 + lastUsed: expect.any(String), 120 + }); 121 + }); 122 + 123 + it('should return false if no metadata exists', async () => { 124 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); 125 + 126 + const resumed = await client.resumeSession(); 127 + 128 + expect(resumed).toBe(false); 129 + }); 130 + 131 + it('should return false and cleanup if session data is missing', async () => { 132 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); 133 + vi.mocked(sessionModule.loadSession).mockResolvedValue(null); 134 + 135 + const resumed = await client.resumeSession(); 136 + 137 + expect(resumed).toBe(false); 138 + expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled(); 139 + }); 140 + 141 + it('should return false and cleanup on resume error', async () => { 142 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); 143 + vi.mocked(sessionModule.loadSession).mockResolvedValue(mockSessionData); 144 + 145 + const agent = client.getAgent(); 146 + vi.mocked(agent.resumeSession).mockRejectedValueOnce(new Error('Resume failed')); 147 + 148 + const resumed = await client.resumeSession(); 149 + 150 + expect(resumed).toBe(false); 151 + expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled(); 152 + }); 153 + }); 154 + 155 + describe('isAuthenticated', () => { 156 + it('should return false when not authenticated', () => { 157 + expect(client.isAuthenticated()).toBe(false); 158 + }); 159 + }); 160 + 161 + describe('getAgent', () => { 162 + it('should return the AtpAgent instance', () => { 163 + const agent = client.getAgent(); 164 + expect(agent).toBeDefined(); 165 + }); 166 + }); 167 + 168 + describe('getSession', () => { 169 + it('should return undefined when not authenticated', () => { 170 + expect(client.getSession()).toBeUndefined(); 171 + }); 172 + }); 173 + });