diff --git a/package-lock.json b/package-lock.json index 346e356..a1d4a4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { - "name": "tangled-cli", + "name": "@markbennett/tang", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "tangled-cli", + "name": "@markbennett/tang", "version": "0.0.1", "license": "MIT", "dependencies": { "@atproto/api": "^0.18.20", + "@atproto/identity": "^0.5.0", "@atproto/lexicon": "^0.6.1", "@inquirer/prompts": "^8.2.0", "@napi-rs/keyring": "^1.2.0", @@ -20,7 +21,7 @@ "zod": "^4.3.6" }, "bin": { - "tangled": "dist/index.js" + "tang": "dist/index.js" }, "devDependencies": { "@atproto/lex-cli": "^0.9.8", @@ -80,6 +81,128 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/@atproto/crypto": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@atproto/crypto/-/crypto-0.5.0.tgz", + "integrity": "sha512-HBfq6z+tzkiBzPhHOvRFkQU3inTQIUJAkb1UEyGx+pdPTnFFtnfIoo9Kr1ZV/FzMIgnHOA8H2UJC3dxxFl1eVg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^1.7.0", + "@noble/hashes": "^1.6.1", + "uint8arrays": "^5.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/crypto/node_modules/multiformats": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/@atproto/crypto/node_modules/uint8arrays": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.1.tgz", + "integrity": "sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "multiformats": "^13.0.0" + } + }, + "node_modules/@atproto/identity": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@atproto/identity/-/identity-0.5.0.tgz", + "integrity": "sha512-9KjpOVhwIMh1CDC3ErcxOof9mfYz+dZrUFRdp1jcaY8xZo4CkQgGBDKG2/6dP1FP4iqfG6r52JGhnTdcCzwBwQ==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.5.0", + "@atproto/crypto": "^0.5.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/identity/node_modules/@atproto/common-web": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.5.0.tgz", + "integrity": "sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.1.0", + "@atproto/lex-json": "^0.1.0", + "@atproto/syntax": "^0.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/identity/node_modules/@atproto/lex-data": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.1.1.tgz", + "integrity": "sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==", + "license": "MIT", + "dependencies": { + "multiformats": "^13.0.0", + "tslib": "^2.8.1", + "uint8arrays": "^5.0.0", + "unicode-segmenter": "^0.14.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/identity/node_modules/@atproto/lex-json": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.1.0.tgz", + "integrity": "sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.1.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/identity/node_modules/@atproto/syntax": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.6.1.tgz", + "integrity": "sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==", + "license": "MIT", + "dependencies": { + "iso-datestring-validator": "^2.2.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/identity/node_modules/multiformats": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/@atproto/identity/node_modules/uint8arrays": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.1.tgz", + "integrity": "sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "multiformats": "^13.0.0" + } + }, + "node_modules/@atproto/identity/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@atproto/lex-cli": { "version": "0.9.8", "resolved": "https://registry.npmjs.org/@atproto/lex-cli/-/lex-cli-0.9.8.tgz", @@ -1392,6 +1515,33 @@ "node": ">= 10" } }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", diff --git a/package.json b/package.json index b55dac9..adc0efd 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "license": "MIT", "dependencies": { "@atproto/api": "^0.18.20", + "@atproto/identity": "^0.5.0", "@atproto/lexicon": "^0.6.1", "@inquirer/prompts": "^8.2.0", "@napi-rs/keyring": "^1.2.0", diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 8eb8355..7f45250 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,5 +1,6 @@ import type { AtpSessionData } from '@atproto/api'; import { AtpAgent } from '@atproto/api'; +import { resolvePdsFromIdentifier } from '../utils/pds-resolver.js'; import { clearCurrentSessionMetadata, deleteSession, @@ -22,13 +23,17 @@ export class TangledApiClient { } /** - * Login with identifier (handle or DID) and password - * Supports custom domain handles (e.g., "markbennett.ca") + * 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 }); @@ -49,7 +54,7 @@ export class TangledApiClient { await saveCurrentSessionMetadata({ handle: sessionData.handle, did: sessionData.did, - pds: this.agent.service.toString(), + pds: pdsUrl, lastUsed: new Date().toISOString(), }); @@ -96,7 +101,8 @@ export class TangledApiClient { return false; } - // Resume session with agent + // Recreate agent pointing at the stored PDS so API calls go to the right server + this.agent = new AtpAgent({ service: metadata.pds }); await this.agent.resumeSession(sessionData); // Update last used timestamp diff --git a/src/utils/pds-resolver.ts b/src/utils/pds-resolver.ts new file mode 100644 index 0000000..2fd9aa6 --- /dev/null +++ b/src/utils/pds-resolver.ts @@ -0,0 +1,20 @@ +import { ensureAtpDocument, IdResolver } from '@atproto/identity'; + +export async function resolvePdsFromIdentifier(identifier: string): Promise { + const resolver = new IdResolver(); + + let did: string; + if (identifier.startsWith('did:')) { + did = identifier; + } else { + const resolved = await resolver.handle.resolve(identifier); + if (!resolved) { + throw new Error(`Could not resolve handle "${identifier}" to a DID`); + } + did = resolved; + } + + const didDoc = await resolver.did.resolveNoCheck(did); + const atpData = ensureAtpDocument(didDoc as Parameters[0]); + return atpData.pds; +} diff --git a/tests/lib/api-client.test.ts b/tests/lib/api-client.test.ts index a7323d6..02fe24e 100644 --- a/tests/lib/api-client.test.ts +++ b/tests/lib/api-client.test.ts @@ -1,37 +1,34 @@ import type { AtpSessionData } from '@atproto/api'; +import { AtpAgent } 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 { KeychainAccessError } from '../../src/lib/session.js'; +import * as pdsResolver from '../../src/utils/pds-resolver.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; - - 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; - }), - }; - }), +// Singleton mock agent — always the same object regardless of how many times +// new AtpAgent() is called. This keeps test references valid even when login() +// and resumeSession() recreate the agent with a different service URL. +// Must be defined with vi.hoisted because vi.mock factories are hoisted to the +// top of the file and run before regular variable declarations. +const { mockAgent } = vi.hoisted(() => { + const agent = { + service: { toString: () => 'https://bsky.social' }, + get session() { + return agent._session; + }, + _session: undefined as AtpSessionData | undefined, + login: vi.fn(), + resumeSession: vi.fn(), }; + return { mockAgent: agent }; }); -// Mock session management (use importOriginal to preserve KeychainAccessError class) +vi.mock('@atproto/api', () => ({ + AtpAgent: vi.fn().mockReturnValue(mockAgent), +})); + vi.mock('../../src/lib/session.js', async (importOriginal) => { const actual = await importOriginal(); return { @@ -45,15 +42,28 @@ vi.mock('../../src/lib/session.js', async (importOriginal) => { }; }); +vi.mock('../../src/utils/pds-resolver.js', () => ({ + resolvePdsFromIdentifier: vi.fn().mockResolvedValue('https://bsky.social'), +})); + describe('TangledApiClient', () => { let client: TangledApiClient; beforeEach(() => { vi.clearAllMocks(); + mockAgent._session = undefined; + + vi.mocked(mockAgent.login).mockImplementation(async () => { + mockAgent._session = mockSessionData; + return { success: true, data: mockSessionData }; + }); + vi.mocked(mockAgent.resumeSession).mockImplementation(async (session: AtpSessionData) => { + mockAgent._session = session; + }); - // Reset mock implementations vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); vi.mocked(sessionModule.loadSession).mockResolvedValue(null); + vi.mocked(pdsResolver.resolvePdsFromIdentifier).mockResolvedValue('https://bsky.social'); client = new TangledApiClient(); }); @@ -72,16 +82,28 @@ describe('TangledApiClient', () => { }); }); - it('should support custom domain handles', async () => { - const result = await client.login('markbennett.ca', 'password'); + it('should resolve PDS from identifier and construct agent with that URL', async () => { + vi.mocked(pdsResolver.resolvePdsFromIdentifier).mockResolvedValue('https://tngl.sh'); - expect(result).toEqual(mockSessionData); - expect(vi.mocked(sessionModule.saveSession)).toHaveBeenCalled(); + await client.login('user.tngl.sh', 'password'); + + expect(pdsResolver.resolvePdsFromIdentifier).toHaveBeenCalledWith('user.tngl.sh'); + expect(AtpAgent).toHaveBeenCalledWith({ service: 'https://tngl.sh' }); + expect(vi.mocked(sessionModule.saveCurrentSessionMetadata)).toHaveBeenCalledWith( + expect.objectContaining({ pds: 'https://tngl.sh' }) + ); + }); + + it('should support DID identifiers', async () => { + vi.mocked(pdsResolver.resolvePdsFromIdentifier).mockResolvedValue('https://tngl.sh'); + + await client.login('did:plc:abc123', 'password'); + + expect(pdsResolver.resolvePdsFromIdentifier).toHaveBeenCalledWith('did:plc:abc123'); }); it('should throw error on login failure', async () => { - const agent = client.getAgent(); - vi.mocked(agent.login).mockResolvedValueOnce({ + vi.mocked(mockAgent.login).mockResolvedValueOnce({ success: false, headers: {}, data: undefined, @@ -91,6 +113,16 @@ describe('TangledApiClient', () => { 'Login failed: No session data received' ); }); + + it('should throw error when PDS resolution fails', async () => { + vi.mocked(pdsResolver.resolvePdsFromIdentifier).mockRejectedValue( + new Error('Could not resolve handle "bad.handle"') + ); + + await expect(client.login('bad.handle', 'password')).rejects.toThrow( + 'Could not resolve handle "bad.handle"' + ); + }); }); describe('logout', () => { @@ -125,6 +157,16 @@ describe('TangledApiClient', () => { }); }); + it('should construct agent with stored PDS URL when resuming', async () => { + const tnglMetadata = { ...mockSessionMetadata, pds: 'https://tngl.sh' }; + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(tnglMetadata); + vi.mocked(sessionModule.loadSession).mockResolvedValue(mockSessionData); + + await client.resumeSession(); + + expect(AtpAgent).toHaveBeenCalledWith({ service: 'https://tngl.sh' }); + }); + it('should return false if no metadata exists', async () => { vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); @@ -146,9 +188,7 @@ describe('TangledApiClient', () => { it('should return false without clearing metadata on transient 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')); + vi.mocked(mockAgent.resumeSession).mockRejectedValueOnce(new Error('Resume failed')); const resumed = await client.resumeSession(); diff --git a/tests/utils/pds-resolver.test.ts b/tests/utils/pds-resolver.test.ts new file mode 100644 index 0000000..87a2cf0 --- /dev/null +++ b/tests/utils/pds-resolver.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resolvePdsFromIdentifier } from '../../src/utils/pds-resolver.js'; + +const PLC_DID = 'did:plc:abc123'; +const WEB_DID = 'did:web:example.com'; +const PDS_URL = 'https://pds.example.com'; + +const mockDidDoc = { + id: PLC_DID, + service: [ + { + id: '#atproto_pds', + type: 'AtprotoPersonalDataServer', + serviceEndpoint: PDS_URL, + }, + ], +}; + +vi.mock('@atproto/identity', () => { + const mockResolveHandle = vi.fn(); + const mockResolveNoCheck = vi.fn(); + + return { + IdResolver: vi.fn().mockImplementation(() => ({ + handle: { resolve: mockResolveHandle }, + did: { resolveNoCheck: mockResolveNoCheck }, + })), + ensureAtpDocument: vi + .fn() + .mockImplementation((doc: { service?: Array<{ id: string; serviceEndpoint: string }> }) => { + const pds = doc?.service?.find((s) => s.id === '#atproto_pds')?.serviceEndpoint; + if (!pds) throw new Error('No PDS in document'); + return { pds }; + }), + _mockResolveHandle: mockResolveHandle, + _mockResolveNoCheck: mockResolveNoCheck, + }; +}); + +async function getMocks() { + const mod = await import('@atproto/identity'); + const m = mod as unknown as { + _mockResolveHandle: ReturnType; + _mockResolveNoCheck: ReturnType; + }; + return { resolveHandle: m._mockResolveHandle, resolveNoCheck: m._mockResolveNoCheck }; +} + +describe('resolvePdsFromIdentifier', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('with a handle', () => { + it('resolves handle to DID then returns PDS from DID document', async () => { + const { resolveHandle, resolveNoCheck } = await getMocks(); + resolveHandle.mockResolvedValue(PLC_DID); + resolveNoCheck.mockResolvedValue(mockDidDoc); + + const result = await resolvePdsFromIdentifier('user.tngl.sh'); + + expect(resolveHandle).toHaveBeenCalledWith('user.tngl.sh'); + expect(resolveNoCheck).toHaveBeenCalledWith(PLC_DID); + expect(result).toBe(PDS_URL); + }); + + it('throws when handle cannot be resolved', async () => { + const { resolveHandle } = await getMocks(); + resolveHandle.mockResolvedValue(undefined); + + await expect(resolvePdsFromIdentifier('unknown.tngl.sh')).rejects.toThrow( + /Could not resolve handle/ + ); + }); + + it('throws when handle resolver throws', async () => { + const { resolveHandle } = await getMocks(); + resolveHandle.mockRejectedValue(new Error('DNS timeout')); + + await expect(resolvePdsFromIdentifier('user.tngl.sh')).rejects.toThrow('DNS timeout'); + }); + }); + + describe('with a did:plc DID', () => { + it('skips handle resolution and returns PDS from DID document', async () => { + const { resolveHandle, resolveNoCheck } = await getMocks(); + resolveNoCheck.mockResolvedValue(mockDidDoc); + + const result = await resolvePdsFromIdentifier(PLC_DID); + + expect(resolveHandle).not.toHaveBeenCalled(); + expect(resolveNoCheck).toHaveBeenCalledWith(PLC_DID); + expect(result).toBe(PDS_URL); + }); + + it('throws when DID document has no PDS service entry', async () => { + const { resolveNoCheck } = await getMocks(); + resolveNoCheck.mockResolvedValue({ id: PLC_DID, service: [] }); + + await expect(resolvePdsFromIdentifier(PLC_DID)).rejects.toThrow(/No PDS/); + }); + + it('throws when DID document resolver throws', async () => { + const { resolveNoCheck } = await getMocks(); + resolveNoCheck.mockRejectedValue(new Error('plc.directory unavailable')); + + await expect(resolvePdsFromIdentifier(PLC_DID)).rejects.toThrow('plc.directory unavailable'); + }); + }); + + describe('with a did:web DID', () => { + it('skips handle resolution and returns PDS from DID document', async () => { + const { resolveHandle, resolveNoCheck } = await getMocks(); + const webDidDoc = { ...mockDidDoc, id: WEB_DID }; + resolveNoCheck.mockResolvedValue(webDidDoc); + + const result = await resolvePdsFromIdentifier(WEB_DID); + + expect(resolveHandle).not.toHaveBeenCalled(); + expect(result).toBe(PDS_URL); + }); + }); +});