diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3514ee3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# CLAUDE.md + +This file provides guidance to Claude Code when working with code in this repository. + +## Project Overview + +ATXP SDK is a TypeScript monorepo providing client and server libraries for the ATXP payment protocol. It supports multiple blockchain networks (Solana, Base, World Chain, Polygon) and integrates with MCP (Model Context Protocol) for AI agent payments. + +## Common Commands + +```bash +# Install dependencies +npm ci + +# Build all packages +npm run build + +# Type check all packages +npm run typecheck + +# Lint all packages +npm run lint +``` + +## Testing + +**IMPORTANT:** Run tests package-by-package because the root level `npm run test` tends to hide test failures. + +```bash +# Run tests for a specific package +npm test -w packages/atxp-common +npm test -w packages/atxp-client +npm test -w packages/atxp-server +npm test -w packages/atxp-express +npm test -w packages/atxp-x402 +# etc. + +# Run all package tests individually (recommended for CI validation) +for pkg in packages/*/; do + echo "Testing $pkg..." + npm test -w "$pkg" || exit 1 +done +``` + +## Architecture + +This is an npm workspaces monorepo with the following packages: + +- `packages/atxp-common` - Shared types and utilities +- `packages/atxp-client` - Client-side SDK for making payments +- `packages/atxp-server` - Server-side SDK for receiving payments +- `packages/atxp-express` - Express.js middleware integration +- `packages/atxp-cloudflare` - Cloudflare Workers integration +- `packages/atxp-base` - Base chain support +- `packages/atxp-solana` - Solana chain support +- `packages/atxp-polygon` - Polygon chain support +- `packages/atxp-worldchain` - World Chain support +- `packages/atxp-x402` - X402 payment protocol support +- `packages/atxp-sqlite` - SQLite storage for OAuth tokens +- `packages/atxp-redis` - Redis storage for OAuth tokens + +## Key Patterns + +### Workspace Dependencies + +Packages depend on each other via workspace references. When adding new types or exports to `@atxp/common`, ensure: +1. The type is exported from `src/index.ts` +2. Run `npm run build -w packages/atxp-common` to regenerate dist files +3. Other packages will pick up changes via TypeScript project references + +### Package Lock Issues + +If you see TypeScript errors about missing properties in workspace packages, check `package-lock.json` for nested `node_modules/@atxp/*` entries that point to npm registry versions instead of local workspace versions. Remove these entries and run `npm ci` again. diff --git a/package-lock.json b/package-lock.json index c1bee79..7941815 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25518,19 +25518,6 @@ "react-native-url-polyfill": "^2.0.0" } }, - "packages/atxp-base/node_modules/@atxp/common": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@atxp/common/-/common-0.10.3.tgz", - "integrity": "sha512-UeFb4yQHnZ0T4ar9Y6qedaN37DcVup2RIxOwv1AhDz9gtf6kAPdWBCnii+yoUk5H8BlzyLZQgzr1yoj4alv4Zw==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.3.0", - "jose": "^6.0.11", - "oauth4webapi": "^3.8.3", - "tweetnacl": "^1.0.3", - "tweetnacl-util": "^0.15.1" - } - }, "packages/atxp-base/node_modules/react-native-url-polyfill": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz", @@ -25573,19 +25560,6 @@ "react-native-url-polyfill": "^3.0.0" } }, - "packages/atxp-client/node_modules/@atxp/common": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@atxp/common/-/common-0.10.3.tgz", - "integrity": "sha512-UeFb4yQHnZ0T4ar9Y6qedaN37DcVup2RIxOwv1AhDz9gtf6kAPdWBCnii+yoUk5H8BlzyLZQgzr1yoj4alv4Zw==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.3.0", - "jose": "^6.0.11", - "oauth4webapi": "^3.8.3", - "tweetnacl": "^1.0.3", - "tweetnacl-util": "^0.15.1" - } - }, "packages/atxp-cloudflare": { "name": "@atxp/cloudflare", "version": "0.10.4", @@ -25653,19 +25627,6 @@ "express": "^5.0.0" } }, - "packages/atxp-express/node_modules/@atxp/common": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@atxp/common/-/common-0.10.3.tgz", - "integrity": "sha512-UeFb4yQHnZ0T4ar9Y6qedaN37DcVup2RIxOwv1AhDz9gtf6kAPdWBCnii+yoUk5H8BlzyLZQgzr1yoj4alv4Zw==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.3.0", - "jose": "^6.0.11", - "oauth4webapi": "^3.8.3", - "tweetnacl": "^1.0.3", - "tweetnacl-util": "^0.15.1" - } - }, "packages/atxp-polygon": { "name": "@atxp/polygon", "version": "0.10.4", diff --git a/packages/atxp-base/src/baseAccount.ts b/packages/atxp-base/src/baseAccount.ts index b471112..05c9ac9 100644 --- a/packages/atxp-base/src/baseAccount.ts +++ b/packages/atxp-base/src/baseAccount.ts @@ -58,4 +58,12 @@ export class BaseAccount implements Account { walletType: 'eoa' }]; } + + /** + * Create a spend permission for the given resource URL. + * Base accounts don't support spend permissions, so this returns null. + */ + async createSpendPermission(_resourceUrl: string): Promise { + return null; + } } \ No newline at end of file diff --git a/packages/atxp-base/src/baseAppAccount.ts b/packages/atxp-base/src/baseAppAccount.ts index 6de8096..89b7de7 100644 --- a/packages/atxp-base/src/baseAppAccount.ts +++ b/packages/atxp-base/src/baseAppAccount.ts @@ -215,4 +215,12 @@ export class BaseAppAccount implements Account { cache.delete(this.toCacheKey(userWalletAddress)); } + + /** + * Create a spend permission for the given resource URL. + * BaseAppAccount doesn't support spend permissions, so this returns null. + */ + async createSpendPermission(_resourceUrl: string): Promise { + return null; + } } \ No newline at end of file diff --git a/packages/atxp-client/src/atxpClient.buildConfig.test.ts b/packages/atxp-client/src/atxpClient.buildConfig.test.ts index f63ef5e..bc3ebf9 100644 --- a/packages/atxp-client/src/atxpClient.buildConfig.test.ts +++ b/packages/atxp-client/src/atxpClient.buildConfig.test.ts @@ -10,7 +10,8 @@ describe('buildConfig', () => { account: { getAccountId: async () => 'bdj' as any, paymentMakers: [], - getSources: async () => [] + getSources: async () => [], + createSpendPermission: async () => null } }); expect(config.oAuthChannelFetch).toBe(fetchFn); diff --git a/packages/atxp-client/src/atxpClient.events.test.ts b/packages/atxp-client/src/atxpClient.events.test.ts index 5b94c68..4d0520c 100644 --- a/packages/atxp-client/src/atxpClient.events.test.ts +++ b/packages/atxp-client/src/atxpClient.events.test.ts @@ -30,7 +30,8 @@ describe('atxpClient events', () => { const account = { getAccountId: vi.fn().mockResolvedValue('bdj'), paymentMakers: [paymentMaker], - getSources: vi.fn().mockResolvedValue([]) + getSources: vi.fn().mockResolvedValue([]), + createSpendPermission: vi.fn().mockResolvedValue(null) }; const client = await atxpClient({ mcpServer: 'https://example.com/mcp', @@ -70,7 +71,8 @@ describe('atxpClient events', () => { const account = { getAccountId: vi.fn().mockResolvedValue('bdj'), paymentMakers: [paymentMaker], - getSources: vi.fn().mockResolvedValue([]) + getSources: vi.fn().mockResolvedValue([]), + createSpendPermission: vi.fn().mockResolvedValue(null) }; // The client initialization or callTool will throw an error due to OAuth failure @@ -114,7 +116,8 @@ describe('atxpClient events', () => { const account = { getAccountId: vi.fn().mockResolvedValue('bdj'), paymentMakers: [paymentMaker], - getSources: vi.fn().mockResolvedValue([]) + getSources: vi.fn().mockResolvedValue([]), + createSpendPermission: vi.fn().mockResolvedValue(null) }; const client = await atxpClient({ mcpServer: 'https://example.com/mcp', @@ -160,7 +163,8 @@ describe('atxpClient events', () => { const account = { getAccountId: vi.fn().mockResolvedValue('bdj'), paymentMakers: [paymentMaker], - getSources: vi.fn().mockResolvedValue([]) + getSources: vi.fn().mockResolvedValue([]), + createSpendPermission: vi.fn().mockResolvedValue(null) }; const client = await atxpClient({ mcpServer: 'https://example.com/mcp', diff --git a/packages/atxp-client/src/atxpClient.test.ts b/packages/atxp-client/src/atxpClient.test.ts index 4fd5eef..cd7e824 100644 --- a/packages/atxp-client/src/atxpClient.test.ts +++ b/packages/atxp-client/src/atxpClient.test.ts @@ -29,7 +29,8 @@ describe('atxpClient', () => { const account = { getAccountId: vi.fn().mockResolvedValue('bdj'), paymentMakers: [paymentMaker], - getSources: vi.fn().mockResolvedValue([]) + getSources: vi.fn().mockResolvedValue([]), + createSpendPermission: vi.fn().mockResolvedValue(null) }; const client = await atxpClient({ mcpServer: 'https://example.com/mcp', @@ -66,7 +67,8 @@ describe('atxpClient', () => { const account = { getAccountId: vi.fn().mockResolvedValue('bdj'), paymentMakers: [paymentMaker], - getSources: vi.fn().mockResolvedValue([]) + getSources: vi.fn().mockResolvedValue([]), + createSpendPermission: vi.fn().mockResolvedValue(null) }; const client = await atxpClient({ mcpServer: 'https://example.com/mcp', @@ -116,7 +118,8 @@ describe('atxpClient', () => { const account = { getAccountId: vi.fn().mockResolvedValue('bdj'), paymentMakers: [paymentMaker], - getSources: vi.fn().mockResolvedValue([]) + getSources: vi.fn().mockResolvedValue([]), + createSpendPermission: vi.fn().mockResolvedValue(null) }; const client = await atxpClient({ mcpServer: 'https://example.com/mcp', diff --git a/packages/atxp-client/src/atxpFetcher.oauth.test.ts b/packages/atxp-client/src/atxpFetcher.oauth.test.ts index 4f942f2..551b952 100644 --- a/packages/atxp-client/src/atxpFetcher.oauth.test.ts +++ b/packages/atxp-client/src/atxpFetcher.oauth.test.ts @@ -24,7 +24,8 @@ function atxpFetcher(fetchFn: FetchLike, paymentMakers?: PaymentMaker[], db?: OA address: 'SolAddress123', chain: 'solana' as any, walletType: 'eoa' as any - }] + }], + createSpendPermission: async () => null }; return new ATXPFetcher({ @@ -208,4 +209,163 @@ describe('atxpFetcher.fetch oauth', () => { const fetcher = atxpFetcher(f.fetchHandler, [brokenPaymentMaker]); await expect(fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) })).rejects.toThrow('Payment maker is missing generateJWT method'); }); + + it('should call createSpendPermission and include token in auth URL for ATXP accounts', async () => { + const f = fetchMock.createInstance(); + mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) + .postOnce('https://example.com/mcp', 401) + .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); + mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) + .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { + const state = new URL(req.args[0] as string).searchParams.get('state'); + return { + status: 301, + headers: {location: `https://atxp.ai?state=${state}&code=testCode`} + }; + }); + + const paymentMaker = { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('testJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + + // Mock an ATXP-style account with createSpendPermission method + const createSpendPermission = vi.fn().mockResolvedValue('spt_test123'); + const atxpAccount: Account & { createSpendPermission: typeof createSpendPermission } = { + getAccountId: async () => "bdj" as any, + paymentMakers: [paymentMaker], + getSources: async () => [{ + address: 'SolAddress123', + chain: 'solana' as any, + walletType: 'eoa' as any + }], + createSpendPermission + }; + + const fetcher = new ATXPFetcher({ + account: atxpAccount, + db: new MemoryOAuthDb(), + destinationMakers: new Map(), + fetchFn: f.fetchHandler + }); + + await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); + + // Verify createSpendPermission was called with the resource URL + expect(createSpendPermission).toHaveBeenCalledWith('https://example.com/mcp'); + + // Verify the authorization URL includes the spend_permission_token + const authCall = f.callHistory.lastCall(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`); + expect(authCall).toBeDefined(); + const authUrl = new URL(authCall!.args[0] as string); + expect(authUrl.searchParams.get('spend_permission_token')).toBe('spt_test123'); + // Also verify resource is set + expect(authUrl.searchParams.get('resource')).toBe('https://example.com/mcp'); + }); + + it('should continue auth flow if createSpendPermission fails', async () => { + const f = fetchMock.createInstance(); + mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) + .postOnce('https://example.com/mcp', 401) + .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); + mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) + .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { + const state = new URL(req.args[0] as string).searchParams.get('state'); + return { + status: 301, + headers: {location: `https://atxp.ai?state=${state}&code=testCode`} + }; + }); + + const paymentMaker = { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('testJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + + // Mock an account where createSpendPermission fails + const createSpendPermission = vi.fn().mockRejectedValue(new Error('Network error')); + const atxpAccount: Account & { createSpendPermission: typeof createSpendPermission } = { + getAccountId: async () => "bdj" as any, + paymentMakers: [paymentMaker], + getSources: async () => [{ + address: 'SolAddress123', + chain: 'solana' as any, + walletType: 'eoa' as any + }], + createSpendPermission + }; + + const fetcher = new ATXPFetcher({ + account: atxpAccount, + db: new MemoryOAuthDb(), + destinationMakers: new Map(), + fetchFn: f.fetchHandler + }); + + // Should not throw - auth flow should continue without spend permission + const response = await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); + expect(response.ok).toBe(true); + + // Verify createSpendPermission was called + expect(createSpendPermission).toHaveBeenCalled(); + + // Verify the authorization URL does NOT include spend_permission_token (since it failed) + const authCall = f.callHistory.lastCall(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`); + const authUrl = new URL(authCall!.args[0] as string); + expect(authUrl.searchParams.get('spend_permission_token')).toBeNull(); + }); + + it('should not include spend_permission_token for accounts that return null', async () => { + const f = fetchMock.createInstance(); + mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) + .postOnce('https://example.com/mcp', 401) + .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); + mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) + .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { + const state = new URL(req.args[0] as string).searchParams.get('state'); + return { + status: 301, + headers: {location: `https://atxp.ai?state=${state}&code=testCode`} + }; + }); + + const paymentMaker = { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('testJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + + // Account that returns null for createSpendPermission (e.g., SolanaAccount, BaseAccount) + const createSpendPermission = vi.fn().mockResolvedValue(null); + const regularAccount: Account = { + getAccountId: async () => "bdj" as any, + paymentMakers: [paymentMaker], + getSources: async () => [{ + address: 'SolAddress123', + chain: 'solana' as any, + walletType: 'eoa' as any + }], + createSpendPermission + }; + + const fetcher = new ATXPFetcher({ + account: regularAccount, + db: new MemoryOAuthDb(), + destinationMakers: new Map(), + fetchFn: f.fetchHandler + }); + + const response = await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); + expect(response.ok).toBe(true); + + // Verify createSpendPermission was called + expect(createSpendPermission).toHaveBeenCalledWith('https://example.com/mcp'); + + // Verify the authorization URL does NOT include spend_permission_token (since it returned null) + const authCall = f.callHistory.lastCall(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`); + const authUrl = new URL(authCall!.args[0] as string); + expect(authUrl.searchParams.get('spend_permission_token')).toBeNull(); + }); }); diff --git a/packages/atxp-client/src/atxpFetcher.payment.test.ts b/packages/atxp-client/src/atxpFetcher.payment.test.ts index 789b154..a582f78 100644 --- a/packages/atxp-client/src/atxpFetcher.payment.test.ts +++ b/packages/atxp-client/src/atxpFetcher.payment.test.ts @@ -36,7 +36,8 @@ function atxpFetcher( address: 'SolAddress123', chain: 'solana' as any, walletType: 'eoa' as any - }] + }], + createSpendPermission: async () => null }; return new ATXPFetcher({ diff --git a/packages/atxp-client/src/atxpFetcher.ts b/packages/atxp-client/src/atxpFetcher.ts index ec6320d..a4c634a 100644 --- a/packages/atxp-client/src/atxpFetcher.ts +++ b/packages/atxp-client/src/atxpFetcher.ts @@ -475,9 +475,26 @@ export class ATXPFetcher { // We can do the full OAuth flow - we'll generate a signed JWT and call /authorize on the // AS to get a code, then exchange the code for an access token const oauthClient = await this.getOAuthClient(); + + // Try to create a spend permission before authorization + // This allows pre-authorizing spending for MCP servers (returns null for account types that don't support it) + let spendPermissionToken: string | null = null; + try { + const result = await this.account.createSpendPermission(error.resourceServerUrl); + if (result) { + this.logger.info(`Created spend permission for resource ${error.resourceServerUrl}`); + this.logger.debug(`Created spend permission token: ${result.substring(0, 8)}...`); + spendPermissionToken = result; + } + } catch (spendPermissionError) { + // Log but don't fail - authorization can still proceed without spend permission + this.logger.warn(`Failed to create spend permission: ${(spendPermissionError as Error).message}`); + } + const authorizationUrl = await oauthClient.makeAuthorizationUrl( error.url, - error.resourceServerUrl + error.resourceServerUrl, + spendPermissionToken ? { spendPermissionToken } : undefined ); if (!this.isAllowedAuthServer(authorizationUrl)) { diff --git a/packages/atxp-client/src/defaultPaymentFailureHandler.test.ts b/packages/atxp-client/src/defaultPaymentFailureHandler.test.ts index be3ea65..f3cbebe 100644 --- a/packages/atxp-client/src/defaultPaymentFailureHandler.test.ts +++ b/packages/atxp-client/src/defaultPaymentFailureHandler.test.ts @@ -42,7 +42,8 @@ describe('Default Payment Failure Handler', () => { const account = { getAccountId: async () => 'test-account' as any, paymentMakers: [], - getSources: async () => [] + getSources: async () => [], + createSpendPermission: async () => null }; const fetcher = new ATXPFetcher({ diff --git a/packages/atxp-client/src/oAuth.ts b/packages/atxp-client/src/oAuth.ts index 6d70f14..ca3026a 100644 --- a/packages/atxp-client/src/oAuth.ts +++ b/packages/atxp-client/src/oAuth.ts @@ -142,7 +142,7 @@ export class OAuthClient extends OAuthResourceClient { return response; } - makeAuthorizationUrl = async (url: string, resourceUrl: string): Promise => { + makeAuthorizationUrl = async (url: string, resourceUrl: string, options?: { spendPermissionToken?: string }): Promise => { resourceUrl = this.normalizeResourceServerUrl(resourceUrl); const authorizationServer = await this.getAuthorizationServer(resourceUrl); const credentials = await this.getClientCredentials(authorizationServer); @@ -154,6 +154,12 @@ export class OAuthClient extends OAuthResourceClient { authorizationUrl.searchParams.set('code_challenge', pkceValues.codeChallenge); authorizationUrl.searchParams.set('code_challenge_method', 'S256'); authorizationUrl.searchParams.set('state', pkceValues.state); + // Add resource URL so auth server knows which MCP server this is for + authorizationUrl.searchParams.set('resource', resourceUrl); + // Add spend permission token if provided (for ATXP accounts with scoped spend permissions) + if (options?.spendPermissionToken) { + authorizationUrl.searchParams.set('spend_permission_token', options.spendPermissionToken); + } return authorizationUrl; } diff --git a/packages/atxp-cloudflare/src/__tests__/atxpCloudflare.test.ts b/packages/atxp-cloudflare/src/__tests__/atxpCloudflare.test.ts index 755cd72..7059ee7 100644 --- a/packages/atxp-cloudflare/src/__tests__/atxpCloudflare.test.ts +++ b/packages/atxp-cloudflare/src/__tests__/atxpCloudflare.test.ts @@ -37,7 +37,8 @@ function mockAccount(accountId: string): Account { return { getAccountId: async () => accountId as any, paymentMakers: [], - getSources: async () => [] + getSources: async () => [], + createSpendPermission: async () => null }; } diff --git a/packages/atxp-cloudflare/src/__tests__/buildConfig.test.ts b/packages/atxp-cloudflare/src/__tests__/buildConfig.test.ts index 6b86eeb..6653097 100644 --- a/packages/atxp-cloudflare/src/__tests__/buildConfig.test.ts +++ b/packages/atxp-cloudflare/src/__tests__/buildConfig.test.ts @@ -17,7 +17,8 @@ function mockAccount(accountId: string): Account { return { getAccountId: async () => accountId as any, paymentMakers: [], - getSources: async () => [] + getSources: async () => [], + createSpendPermission: async () => null }; } diff --git a/packages/atxp-cloudflare/src/__tests__/requirePayment.test.ts b/packages/atxp-cloudflare/src/__tests__/requirePayment.test.ts index bd1368f..55e8521 100644 --- a/packages/atxp-cloudflare/src/__tests__/requirePayment.test.ts +++ b/packages/atxp-cloudflare/src/__tests__/requirePayment.test.ts @@ -27,7 +27,8 @@ function mockAccount(accountId: string): Account { return { getAccountId: async () => accountId as any, paymentMakers: [], - getSources: async () => [] + getSources: async () => [], + createSpendPermission: async () => null }; } diff --git a/packages/atxp-common/src/atxpAccount.test.ts b/packages/atxp-common/src/atxpAccount.test.ts new file mode 100644 index 0000000..dd860f6 --- /dev/null +++ b/packages/atxp-common/src/atxpAccount.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ATXPAccount } from './atxpAccount.js'; + +describe('ATXPAccount', () => { + describe('constructor', () => { + it('should parse connection string with token and account_id', () => { + const connectionString = 'https://accounts.example.com?connection_token=ct_abc123&account_id=atxp_acct_xyz'; + const account = new ATXPAccount(connectionString); + expect(account.origin).toBe('https://accounts.example.com'); + expect(account.token).toBe('ct_abc123'); + }); + + it('should throw if connection string is empty', () => { + expect(() => new ATXPAccount('')).toThrow('connection string is empty'); + }); + + it('should throw if connection token is missing', () => { + expect(() => new ATXPAccount('https://accounts.example.com')).toThrow('missing connection token'); + }); + }); + + describe('createSpendPermission', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + }); + + it('should call /spend-permission with Bearer auth and return token', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ spendPermissionToken: 'spt_test123' }), + }); + + const account = new ATXPAccount( + 'https://accounts.example.com?connection_token=ct_abc123&account_id=atxp_acct_xyz', + { fetchFn: mockFetch } + ); + + const token = await account.createSpendPermission('https://my-mcp-server.com'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://accounts.example.com/spend-permission', + { + method: 'POST', + headers: { + 'Authorization': 'Bearer ct_abc123', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ resourceUrl: 'https://my-mcp-server.com' }), + } + ); + expect(token).toBe('spt_test123'); + }); + + it('should throw if response is not ok', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + text: async () => 'Invalid resourceUrl', + }); + + const account = new ATXPAccount( + 'https://accounts.example.com?connection_token=ct_abc123&account_id=atxp_acct_xyz', + { fetchFn: mockFetch } + ); + + await expect(account.createSpendPermission('invalid-url')).rejects.toThrow( + '/spend-permission failed: 400 Bad Request' + ); + }); + + it('should throw if response does not contain spendPermissionToken', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ someOtherField: 'value' }), + }); + + const account = new ATXPAccount( + 'https://accounts.example.com?connection_token=ct_abc123&account_id=atxp_acct_xyz', + { fetchFn: mockFetch } + ); + + await expect(account.createSpendPermission('https://mcp.example.com')).rejects.toThrow( + 'did not return spendPermissionToken' + ); + }); + }); + + describe('getAccountId', () => { + it('should return cached account ID from connection string', async () => { + const account = new ATXPAccount( + 'https://accounts.example.com?connection_token=ct_abc123&account_id=atxp_acct_xyz' + ); + const accountId = await account.getAccountId(); + expect(accountId).toBe('atxp:atxp_acct_xyz'); + }); + + it('should fetch account ID from /me if not in connection string', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ accountId: 'atxp_acct_fetched' }), + }); + + const account = new ATXPAccount( + 'https://accounts.example.com?connection_token=ct_abc123', + { fetchFn: mockFetch } + ); + + const accountId = await account.getAccountId(); + expect(accountId).toBe('atxp:atxp_acct_fetched'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://accounts.example.com/me', + expect.objectContaining({ + method: 'GET', + headers: { + 'Authorization': 'Bearer ct_abc123', + 'Accept': 'application/json', + }, + }) + ); + }); + }); +}); diff --git a/packages/atxp-common/src/atxpAccount.ts b/packages/atxp-common/src/atxpAccount.ts index b31f4fd..e39b83a 100644 --- a/packages/atxp-common/src/atxpAccount.ts +++ b/packages/atxp-common/src/atxpAccount.ts @@ -255,4 +255,35 @@ export class ATXPAccount implements Account { return json; } + + /** + * Create a spend permission for the given resource URL. + * This is an ATXP-specific feature that allows pre-authorizing spending + * for a specific MCP server during OAuth authorization. + * + * @param resourceUrl - The MCP server URL to create a spend permission for + * @returns The spend permission token to pass to the authorization URL + */ + async createSpendPermission(resourceUrl: string): Promise { + const response = await this.fetchFn(`${this.origin}/spend-permission`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ resourceUrl }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`ATXPAccount: /spend-permission failed: ${response.status} ${response.statusText} ${text}`); + } + + const json = await response.json() as { spendPermissionToken?: string }; + if (!json?.spendPermissionToken) { + throw new Error('ATXPAccount: /spend-permission did not return spendPermissionToken'); + } + + return json.spendPermissionToken; + } } diff --git a/packages/atxp-common/src/types.ts b/packages/atxp-common/src/types.ts index 8232576..f2df6f0 100644 --- a/packages/atxp-common/src/types.ts +++ b/packages/atxp-common/src/types.ts @@ -175,6 +175,14 @@ export interface PaymentDestination { */ export type Account = PaymentDestination & { paymentMakers: PaymentMaker[]; + /** + * Create a spend permission for the given resource URL. + * This allows pre-authorizing spending for a specific MCP server during OAuth authorization. + * + * @param resourceUrl - The MCP server URL to create a spend permission for + * @returns The spend permission token, or null if this account type doesn't support spend permissions + */ + createSpendPermission: (resourceUrl: string) => Promise; } /** diff --git a/packages/atxp-polygon/src/polygonBrowserAccount.ts b/packages/atxp-polygon/src/polygonBrowserAccount.ts index 02b32d2..4d0682e 100644 --- a/packages/atxp-polygon/src/polygonBrowserAccount.ts +++ b/packages/atxp-polygon/src/polygonBrowserAccount.ts @@ -110,4 +110,12 @@ export class PolygonBrowserAccount implements Account { static clearAllCachedData(_userWalletAddress: string, _cache?: unknown): void { // No-op: Direct Wallet mode doesn't cache any data } + + /** + * Create a spend permission for the given resource URL. + * PolygonBrowserAccount doesn't support spend permissions, so this returns null. + */ + async createSpendPermission(_resourceUrl: string): Promise { + return null; + } } diff --git a/packages/atxp-polygon/src/polygonServerAccount.ts b/packages/atxp-polygon/src/polygonServerAccount.ts index aa2f874..25dfa73 100644 --- a/packages/atxp-polygon/src/polygonServerAccount.ts +++ b/packages/atxp-polygon/src/polygonServerAccount.ts @@ -96,4 +96,12 @@ export class PolygonServerAccount implements Account { walletType: WalletTypeEnum.EOA }]; } + + /** + * Create a spend permission for the given resource URL. + * PolygonServerAccount doesn't support spend permissions, so this returns null. + */ + async createSpendPermission(_resourceUrl: string): Promise { + return null; + } } diff --git a/packages/atxp-server/src/paymentServer.test.ts b/packages/atxp-server/src/paymentServer.test.ts index c66dd07..cecea49 100644 --- a/packages/atxp-server/src/paymentServer.test.ts +++ b/packages/atxp-server/src/paymentServer.test.ts @@ -2,17 +2,28 @@ import { describe, it, expect } from 'vitest'; import { ATXPPaymentServer } from './paymentServer.js'; import * as TH from './serverTestHelpers.js'; import fetchMock from 'fetch-mock'; +import { MemoryOAuthDb, AuthorizationServerUrl } from '@atxp/common'; + +// Helper to create OAuthDb with credentials stored +async function createOAuthDbWithCredentials(server: AuthorizationServerUrl, clientId: string, clientSecret: string): Promise { + const db = new MemoryOAuthDb(); + await db.saveClientCredentials(server, { clientId, clientSecret }); + return db; +} describe('ATXPPaymentServer', () => { - it('should call the charge endpoint', async () => { + it('should call the charge endpoint with client credentials', async () => { const mock = fetchMock.createInstance(); mock.post('https://auth.atxp.ai/charge', { status: 200, body: { success: true } }); - // Create server instance - const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler); + // Create OAuthDb with credentials + const oAuthDb = await createOAuthDbWithCredentials('https://auth.atxp.ai', 'test-client-id', 'test-client-secret'); + + // Create server instance with credentials + const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler, oAuthDb); const chargeParams = TH.charge({ sourceAccountId: 'solana:test-source', @@ -24,49 +35,47 @@ describe('ATXPPaymentServer', () => { // Verify the result expect(result).toBe(true); - // Verify fetch was called with correct parameters + // Verify fetch was called with correct parameters including auth header const call = mock.callHistory.lastCall('https://auth.atxp.ai/charge'); expect(call).toBeDefined(); expect(call?.options.method).toBe('post'); - expect(call?.options.headers).toEqual({ - 'content-type': 'application/json' - }); + + // Verify Authorization header is present with Basic auth + const expectedCredentials = Buffer.from('test-client-id:test-client-secret').toString('base64'); + expect((call?.options.headers as Record)?.['authorization']).toBe(`Basic ${expectedCredentials}`); + const parsedBody = JSON.parse(call?.options.body as string); expect(parsedBody.sourceAccountId).toEqual(chargeParams.sourceAccountId); expect(parsedBody.destinationAccountId).toEqual(chargeParams.destinationAccountId); expect(parsedBody.options).toBeDefined(); expect(parsedBody.options[0].amount).toEqual(chargeParams.options[0].amount.toString()); - - // Credentials were fetched from the real database }); - it('should make requests without authorization headers', async () => { + it('should throw error when credentials are not configured', async () => { const mock = fetchMock.createInstance(); mock.post('https://auth.atxp.ai/charge', { status: 200, body: { success: true } }); + // Create server instance WITHOUT credentials (empty MemoryOAuthDb) const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler); - await server.charge(TH.charge({ + await expect(server.charge(TH.charge({ sourceAccountId: 'solana:test-source', destinationAccountId: 'solana:test-destination' - })); - - // Verify no authorization header is included - const call = mock.callHistory.lastCall('https://auth.atxp.ai/charge'); - expect((call?.options?.headers as any)?.['authorization']).toBeUndefined(); + }))).rejects.toThrow('Missing client credentials'); }); - it('should call the create payment request endpoint', async () => { + it('should call the create payment request endpoint with credentials', async () => { const mock = fetchMock.createInstance(); mock.post('https://auth.atxp.ai/payment-request', { status: 200, body: { id: 'test-payment-request-id' } }); - const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler); + const oAuthDb = await createOAuthDbWithCredentials('https://auth.atxp.ai', 'test-client-id', 'test-client-secret'); + const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler, oAuthDb); const paymentRequestParams = TH.charge({ sourceAccountId: 'solana:test-source', @@ -78,38 +87,21 @@ describe('ATXPPaymentServer', () => { // Verify the result expect(result).toBe('test-payment-request-id'); - // Verify fetch was called with correct parameters + // Verify fetch was called with correct parameters including auth header const call = mock.callHistory.lastCall('https://auth.atxp.ai/payment-request'); expect(call).toBeDefined(); expect(call?.options.method).toBe('post'); - expect(call?.options.headers).toEqual({ - 'content-type': 'application/json' - }); + + // Verify Authorization header is present + const expectedCredentials = Buffer.from('test-client-id:test-client-secret').toString('base64'); + expect((call?.options.headers as Record)?.['authorization']).toBe(`Basic ${expectedCredentials}`); + const parsedBody = JSON.parse(call?.options.body as string); expect(parsedBody.sourceAccountId).toEqual(paymentRequestParams.sourceAccountId); expect(parsedBody.destinationAccountId).toEqual(paymentRequestParams.destinationAccountId); expect(parsedBody.options).toBeDefined(); }); - it('should make payment request without authorization headers', async () => { - const mock = fetchMock.createInstance(); - mock.post('https://auth.atxp.ai/payment-request', { - status: 200, - body: { id: 'test-payment-request-id' } - }); - - const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler); - - await server.createPaymentRequest(TH.charge({ - sourceAccountId: 'solana:test-source', - destinationAccountId: 'solana:test-destination' - })); - - // Verify no authorization header is included - const call = mock.callHistory.lastCall('https://auth.atxp.ai/payment-request'); - expect((call?.options?.headers as any)?.['authorization']).toBeUndefined(); - }); - it('should handle charge endpoint returning 402 status (payment required)', async () => { const mock = fetchMock.createInstance(); mock.post('https://auth.atxp.ai/charge', { @@ -121,7 +113,8 @@ describe('ATXPPaymentServer', () => { } }); - const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler); + const oAuthDb = await createOAuthDbWithCredentials('https://auth.atxp.ai', 'test-client-id', 'test-client-secret'); + const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler, oAuthDb); const result = await server.charge(TH.charge({ sourceAccountId: 'solana:test-source', @@ -139,7 +132,8 @@ describe('ATXPPaymentServer', () => { body: { error: 'server error' } }); - const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler); + const oAuthDb = await createOAuthDbWithCredentials('https://auth.atxp.ai', 'test-client-id', 'test-client-secret'); + const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler, oAuthDb); await expect(server.charge(TH.charge({ sourceAccountId: 'solana:test-source', @@ -154,7 +148,8 @@ describe('ATXPPaymentServer', () => { body: { error: 'bad request' } }); - const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler); + const oAuthDb = await createOAuthDbWithCredentials('https://auth.atxp.ai', 'test-client-id', 'test-client-secret'); + const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler, oAuthDb); await expect(server.createPaymentRequest(TH.charge({ sourceAccountId: 'solana:test-source', @@ -169,7 +164,8 @@ describe('ATXPPaymentServer', () => { body: { success: true } // Missing 'id' field }); - const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler); + const oAuthDb = await createOAuthDbWithCredentials('https://auth.atxp.ai', 'test-client-id', 'test-client-secret'); + const server = new ATXPPaymentServer('https://auth.atxp.ai', TH.logger(), mock.fetchHandler, oAuthDb); await expect(server.createPaymentRequest(TH.charge({ sourceAccountId: 'solana:test-source', diff --git a/packages/atxp-server/src/paymentServer.ts b/packages/atxp-server/src/paymentServer.ts index ae9c6d5..7c41dd7 100644 --- a/packages/atxp-server/src/paymentServer.ts +++ b/packages/atxp-server/src/paymentServer.ts @@ -1,5 +1,5 @@ import { PaymentServer, Charge } from "./types.js"; -import { AuthorizationServerUrl, FetchLike, Logger } from "@atxp/common"; +import { AuthorizationServerUrl, FetchLike, Logger, OAuthDb, MemoryOAuthDb } from "@atxp/common"; /** * Expected error response format from ATXP payment server @@ -15,22 +15,29 @@ interface PaymentServerErrorResponse { /** * ATXP Payment Server implementation - * + * * This class handles payment operations with the ATXP authorization server. - * + * * @example * ```typescript * const paymentServer = new ATXPPaymentServer( * 'https://auth.atxp.ai', - * logger + * logger, + * fetch, + * oAuthDb // For looking up client credentials * ); * ``` */ export class ATXPPaymentServer implements PaymentServer { + private readonly oAuthDb: OAuthDb; + constructor( private readonly server: AuthorizationServerUrl, private readonly logger: Logger, - private readonly fetchFn: FetchLike = fetch.bind(globalThis)) { + private readonly fetchFn: FetchLike = fetch.bind(globalThis), + oAuthDb?: OAuthDb) { + // Default to MemoryOAuthDb if not provided + this.oAuthDb = oAuthDb ?? new MemoryOAuthDb(); } charge = async(chargeRequest: Charge): Promise => { @@ -106,12 +113,12 @@ export class ATXPPaymentServer implements PaymentServer { /** * Makes authenticated requests to the ATXP authorization server - * + * * @param method - HTTP method ('GET' or 'POST') * @param path - API endpoint path * @param body - Request body (for POST requests) * @returns Promise - The HTTP response from the server - * + * * @example * ```typescript * const response = await paymentServer.makeRequest('POST', '/charge', { @@ -123,11 +130,27 @@ export class ATXPPaymentServer implements PaymentServer { */ protected makeRequest = async(method: 'GET' | 'POST', path: string, body: unknown): Promise => { const url = new URL(path, this.server); + + const headers: Record = { + 'Content-Type': 'application/json' + }; + + // Add Basic auth header with client credentials + // This authenticates the MCP server to enable resource_url validation + const credentials = await this.oAuthDb.getClientCredentials(this.server); + if (!credentials?.clientId || !credentials?.clientSecret) { + throw new Error( + `Missing client credentials for authorization server ${this.server}. ` + + `Ensure the MCP server has been registered and credentials are stored in the OAuthDb.` + ); + } + const credentialString = `${credentials.clientId}:${credentials.clientSecret}`; + const base64Credentials = Buffer.from(credentialString).toString('base64'); + headers['Authorization'] = `Basic ${base64Credentials}`; + const response = await this.fetchFn(url, { method, - headers: { - 'Content-Type': 'application/json' - }, + headers, body: JSON.stringify(body) }); return response; diff --git a/packages/atxp-server/src/serverConfig.test.ts b/packages/atxp-server/src/serverConfig.test.ts index 3a12d24..101fd45 100644 --- a/packages/atxp-server/src/serverConfig.test.ts +++ b/packages/atxp-server/src/serverConfig.test.ts @@ -9,6 +9,7 @@ function mockAccount(accountId: string): Account { getAccountId: async () => `base:${accountId}` as any, paymentMakers: [], getSources: async () => [], + createSpendPermission: async () => null, }; } diff --git a/packages/atxp-server/src/serverConfig.ts b/packages/atxp-server/src/serverConfig.ts index d2ce8de..79e0397 100644 --- a/packages/atxp-server/src/serverConfig.ts +++ b/packages/atxp-server/src/serverConfig.ts @@ -46,7 +46,8 @@ export function buildServerConfig(args: ATXPArgs): ATXPConfig { atxpConnectionToken }); const logger = withDefaults.logger ?? new ConsoleLogger(); - const paymentServer = withDefaults.paymentServer ?? new ATXPPaymentServer(withDefaults.server, logger) + // Pass oAuthDb to PaymentServer so it can authenticate /charge requests with client credentials + const paymentServer = withDefaults.paymentServer ?? new ATXPPaymentServer(withDefaults.server, logger, fetch.bind(globalThis), oAuthDb) const built = { oAuthDb, oAuthClient, paymentServer, logger}; return Object.freeze({ ...withDefaults, ...built }); diff --git a/packages/atxp-server/src/serverTestHelpers.ts b/packages/atxp-server/src/serverTestHelpers.ts index 6c408b4..d303c89 100644 --- a/packages/atxp-server/src/serverTestHelpers.ts +++ b/packages/atxp-server/src/serverTestHelpers.ts @@ -21,6 +21,7 @@ export function mockAccount(accountId: string): Account { getAccountId: async () => formattedAccountId as any, paymentMakers: [], getSources: async () => [], + createSpendPermission: async () => null, }; } diff --git a/packages/atxp-solana/src/solanaAccount.ts b/packages/atxp-solana/src/solanaAccount.ts index 0eb3e1a..4f68f24 100644 --- a/packages/atxp-solana/src/solanaAccount.ts +++ b/packages/atxp-solana/src/solanaAccount.ts @@ -43,4 +43,12 @@ export class SolanaAccount implements Account { walletType: 'eoa' }]; } + + /** + * Create a spend permission for the given resource URL. + * Solana accounts don't support spend permissions, so this returns null. + */ + async createSpendPermission(_resourceUrl: string): Promise { + return null; + } } \ No newline at end of file diff --git a/packages/atxp-worldchain/src/worldchainAccount.ts b/packages/atxp-worldchain/src/worldchainAccount.ts index 7582a05..194ec38 100644 --- a/packages/atxp-worldchain/src/worldchainAccount.ts +++ b/packages/atxp-worldchain/src/worldchainAccount.ts @@ -218,4 +218,12 @@ export class WorldchainAccount implements Account { cache.delete(this.toCacheKey(userWalletAddress)); } + + /** + * Create a spend permission for the given resource URL. + * WorldchainAccount doesn't support spend permissions, so this returns null. + */ + async createSpendPermission(_resourceUrl: string): Promise { + return null; + } } \ No newline at end of file