From ccebce98d7475e2c45315acbd8a94d5d047556ed Mon Sep 17 00:00:00 2001 From: Mark Bennett Date: Sun, 8 Feb 2026 21:52:18 -0700 Subject: [PATCH] Implement session management with OS keychain integration - Save/load/delete session data from keychain - Use @napi-rs/keyring for cross-platform support - Track current session metadata - Error handling for keychain operations - Comprehensive unit tests with mocked keychain - Fix AtpSessionData type compliance (add required 'active' field) Co-Authored-By: Claude Sonnet 4.5 --- .claude/settings.json | 6 +- package.json | 8 +- src/lib/session.ts | 96 ++++++++++++++++++ tests/lib/session.test.ts | 203 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 301 insertions(+), 12 deletions(-) create mode 100644 src/lib/session.ts create mode 100644 tests/lib/session.test.ts diff --git a/.claude/settings.json b/.claude/settings.json index 5260c26..888116d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,9 +1,5 @@ { "permissions": { - "allow": [ - "Bash(npm run test:*)", - "Bash(npm run build:*)", - "Bash(npm test:*)" - ] + "allow": ["Bash(npm run test:*)", "Bash(npm run build:*)", "Bash(npm test:*)"] } } diff --git a/package.json b/package.json index cb382de..947b004 100644 --- a/package.json +++ b/package.json @@ -26,13 +26,7 @@ "type": "git", "url": "git@tangled.org:markbennett.ca/tangled-cli" }, - "keywords": [ - "git", - "tangled", - "pds", - "atproto", - "cli" - ], + "keywords": ["git", "tangled", "pds", "atproto", "cli"], "author": "Mark Bennett", "license": "MIT", "dependencies": { diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..a1dadbf --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,96 @@ +import type { AtpSessionData } from '@atproto/api'; +import { AsyncEntry } from '@napi-rs/keyring'; + +const SERVICE_NAME = 'tangled-cli'; + +export interface SessionMetadata { + handle: string; + did: string; + pds: string; + lastUsed: string; // ISO timestamp +} + +/** + * Store session data in OS keychain + * @param sessionData - Session data from AtpAgent + */ +export async function saveSession(sessionData: AtpSessionData): Promise { + try { + const accountId = sessionData.did || sessionData.handle; + if (!accountId) { + throw new Error('Session data must include DID or handle'); + } + + const serialized = JSON.stringify(sessionData); + const entry = new AsyncEntry(SERVICE_NAME, accountId); + await entry.setPassword(serialized); + } catch (error) { + throw new Error( + `Failed to save session to keychain: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Retrieve session data from OS keychain + * @param accountId - User's DID or handle + */ +export async function loadSession(accountId: string): Promise { + try { + const entry = new AsyncEntry(SERVICE_NAME, accountId); + const serialized = await entry.getPassword(); + if (!serialized) { + return null; + } + return JSON.parse(serialized) as AtpSessionData; + } catch (error) { + throw new Error( + `Failed to load session from keychain: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Delete session from OS keychain + * @param accountId - User's DID or handle + */ +export async function deleteSession(accountId: string): Promise { + try { + const entry = new AsyncEntry(SERVICE_NAME, accountId); + return await entry.deleteCredential(); + } catch (error) { + throw new Error( + `Failed to delete session from keychain: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Store metadata about current session for CLI to track active user + * Uses a special "current" account in keychain + */ +export async function saveCurrentSessionMetadata(metadata: SessionMetadata): Promise { + const serialized = JSON.stringify(metadata); + const entry = new AsyncEntry(SERVICE_NAME, 'current-session-metadata'); + await entry.setPassword(serialized); +} + +/** + * Get metadata about current active session + */ +export async function getCurrentSessionMetadata(): Promise { + const entry = new AsyncEntry(SERVICE_NAME, 'current-session-metadata'); + const serialized = await entry.getPassword(); + if (!serialized) { + return null; + } + return JSON.parse(serialized) as SessionMetadata; +} + +/** + * Clear current session metadata + */ +export async function clearCurrentSessionMetadata(): Promise { + const entry = new AsyncEntry(SERVICE_NAME, 'current-session-metadata'); + await entry.deleteCredential(); +} diff --git a/tests/lib/session.test.ts b/tests/lib/session.test.ts new file mode 100644 index 0000000..0746f45 --- /dev/null +++ b/tests/lib/session.test.ts @@ -0,0 +1,203 @@ +import type { AtpSessionData } from '@atproto/api'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type SessionMetadata, + clearCurrentSessionMetadata, + deleteSession, + getCurrentSessionMetadata, + loadSession, + saveCurrentSessionMetadata, + saveSession, +} from '../../src/lib/session.js'; + +// Mock @napi-rs/keyring +vi.mock('@napi-rs/keyring', () => { + const mockStorage = new Map(); + + return { + AsyncEntry: vi.fn().mockImplementation((service: string, account: string) => { + const key = `${service}:${account}`; + + return { + setPassword: vi.fn().mockImplementation(async (password: string) => { + mockStorage.set(key, password); + }), + getPassword: vi.fn().mockImplementation(async () => { + return mockStorage.get(key) || null; + }), + deleteCredential: vi.fn().mockImplementation(async () => { + return mockStorage.delete(key); + }), + }; + }), + // Export the storage for test access + __mockStorage: mockStorage, + }; +}); + +describe('Session Management', () => { + beforeEach(async () => { + // Clear mock storage before each test + // biome-ignore lint/suspicious/noExplicitAny: accessing mock-specific property + const keyring = (await import('@napi-rs/keyring')) as any; + if (keyring.__mockStorage instanceof Map) { + keyring.__mockStorage.clear(); + } + vi.clearAllMocks(); + }); + + afterEach(async () => { + // Clean up after each test + // biome-ignore lint/suspicious/noExplicitAny: accessing mock-specific property + const keyring = (await import('@napi-rs/keyring')) as any; + if (keyring.__mockStorage instanceof Map) { + keyring.__mockStorage.clear(); + } + }); + + describe('saveSession', () => { + it('should save session to keychain using DID', async () => { + const sessionData: AtpSessionData = { + did: 'did:plc:test123', + handle: 'user.bsky.social', + email: 'user@example.com', + emailConfirmed: true, + active: true, + accessJwt: 'token123', + refreshJwt: 'refresh123', + }; + + await saveSession(sessionData); + + // Verify session was stored + const loaded = await loadSession('did:plc:test123'); + expect(loaded).toEqual(sessionData); + }); + + it('should save and retrieve session with all required fields', async () => { + const sessionData: AtpSessionData = { + did: 'did:plc:test456', + handle: 'user.bsky.social', + email: 'user@example.com', + emailConfirmed: true, + active: true, + accessJwt: 'token123', + refreshJwt: 'refresh123', + }; + + await saveSession(sessionData); + + // Verify session was stored (keyed by DID) + const loaded = await loadSession('did:plc:test456'); + expect(loaded).toEqual(sessionData); + }); + + it('should throw error if session has no DID or handle', async () => { + const sessionData = { + email: 'user@example.com', + accessJwt: 'token123', + refreshJwt: 'refresh123', + } as AtpSessionData; + + await expect(saveSession(sessionData)).rejects.toThrow( + 'Session data must include DID or handle' + ); + }); + }); + + describe('loadSession', () => { + it('should load session from keychain', async () => { + const sessionData: AtpSessionData = { + did: 'did:plc:test123', + handle: 'user.bsky.social', + active: true, + accessJwt: 'token123', + refreshJwt: 'refresh123', + }; + + await saveSession(sessionData); + const result = await loadSession('did:plc:test123'); + + expect(result).toEqual(sessionData); + }); + + it('should return null when session not found', async () => { + const result = await loadSession('did:plc:notfound'); + expect(result).toBeNull(); + }); + }); + + describe('deleteSession', () => { + it('should delete session from keychain', async () => { + const sessionData: AtpSessionData = { + did: 'did:plc:test123', + handle: 'user.bsky.social', + active: true, + accessJwt: 'token123', + refreshJwt: 'refresh123', + }; + + await saveSession(sessionData); + + // Verify session exists + let loaded = await loadSession('did:plc:test123'); + expect(loaded).toEqual(sessionData); + + // Delete session + const deleted = await deleteSession('did:plc:test123'); + expect(deleted).toBe(true); + + // Verify session no longer exists + loaded = await loadSession('did:plc:test123'); + expect(loaded).toBeNull(); + }); + + it('should return false when deleting non-existent session', async () => { + const deleted = await deleteSession('did:plc:notfound'); + expect(deleted).toBe(false); + }); + }); + + describe('session metadata', () => { + it('should save and load current session metadata', async () => { + const metadata: SessionMetadata = { + handle: 'user.bsky.social', + did: 'did:plc:test123', + pds: 'https://bsky.social', + lastUsed: new Date().toISOString(), + }; + + await saveCurrentSessionMetadata(metadata); + const result = await getCurrentSessionMetadata(); + + expect(result).toEqual(metadata); + }); + + it('should return null when no metadata exists', async () => { + const result = await getCurrentSessionMetadata(); + expect(result).toBeNull(); + }); + + it('should clear current session metadata', async () => { + const metadata: SessionMetadata = { + handle: 'user.bsky.social', + did: 'did:plc:test123', + pds: 'https://bsky.social', + lastUsed: new Date().toISOString(), + }; + + await saveCurrentSessionMetadata(metadata); + + // Verify metadata exists + let result = await getCurrentSessionMetadata(); + expect(result).toEqual(metadata); + + // Clear metadata + await clearCurrentSessionMetadata(); + + // Verify metadata no longer exists + result = await getCurrentSessionMetadata(); + expect(result).toBeNull(); + }); + }); +}); -- 2.51.2