import type { AtpPersistSessionHandler, AtpSessionData } from '@atproto/api'; import { AtpAgent } from '@atproto/api'; import { isAccessTokenValid } from '../utils/jwt.js'; import { resolvePdsFromIdentifier } from '../utils/pds-resolver.js'; import { clearCurrentSessionMetadata, deleteSession, getCurrentSessionMetadata, KeychainAccessError, 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. * Resolves the user's PDS from their handle or DID before authenticating, * so accounts on any PDS (not just bsky.social) work correctly. * * @param identifier - User's handle or DID * @param password - App password */ async login(identifier: string, password: string): Promise { const pdsUrl = await resolvePdsFromIdentifier(identifier); this.agent = new AtpAgent({ service: pdsUrl }); 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: pdsUrl, 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; } // The agent rotates the single-use refresh token on every refresh and // hands us the new tokens through this callback. Persist them so the next // CLI invocation presents a valid refresh token instead of a consumed one. // This fires both for the forced refresh below and for any lazy refresh // the agent performs mid-command when an access token expires. let pendingSave: Promise | undefined; const persistSession: AtpPersistSessionHandler = (event, session) => { if (session && (event === 'update' || event === 'create')) { pendingSave = saveSession(session); } }; // Recreate agent pointing at the stored PDS so API calls go to the right server this.agent = new AtpAgent({ service: metadata.pds, persistSession }); if (isAccessTokenValid(sessionData.accessJwt)) { // The stored access token is still good. Install it without forcing a // refresh so the single-use refresh token is left untouched. Forcing a // refresh on every command rotates that token constantly; if a command // is interrupted or overlaps another invocation, the stored token gets // revoked server-side before its replacement is saved, permanently // breaking auth until the user logs in again. The agent will refresh // lazily (and persist via the callback) only when the access token // actually expires mid-request. this.agent.sessionManager.session = sessionData; } else { // Access token is expired or unreadable: refresh now. resumeSession // forces a refresh, which rotates the refresh token and fires // persistSession with the new tokens. Await the resulting save so the // rotated token is durable before this invocation continues. await this.agent.resumeSession(sessionData); await pendingSave; } // Update last used timestamp await saveCurrentSessionMetadata({ ...metadata, lastUsed: new Date().toISOString(), }); return true; } catch (error) { if (error instanceof KeychainAccessError) { // Don't clear credentials — keychain may just be temporarily locked throw error; } // Session resume failed (network error, expired refresh token, etc.) // Don't clear credentials — the error may be transient. The user can // run "auth login" explicitly if they need to re-authenticate. 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); }