@wilnertech/halopsa-mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +19 -0
- package/LICENSE +21 -0
- package/README.md +270 -0
- package/dist/api/client.d.ts +85 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +297 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/errors.d.ts +60 -0
- package/dist/api/errors.d.ts.map +1 -0
- package/dist/api/errors.js +188 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/cache/memory-cache.d.ts +89 -0
- package/dist/cache/memory-cache.d.ts.map +1 -0
- package/dist/cache/memory-cache.js +175 -0
- package/dist/cache/memory-cache.js.map +1 -0
- package/dist/cache/prewarm.d.ts +12 -0
- package/dist/cache/prewarm.d.ts.map +1 -0
- package/dist/cache/prewarm.js +55 -0
- package/dist/cache/prewarm.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +141 -0
- package/dist/index.js.map +1 -0
- package/dist/schemas/common.d.ts +212 -0
- package/dist/schemas/common.d.ts.map +1 -0
- package/dist/schemas/common.js +127 -0
- package/dist/schemas/common.js.map +1 -0
- package/dist/tools/assets.d.ts +482 -0
- package/dist/tools/assets.d.ts.map +1 -0
- package/dist/tools/assets.js +732 -0
- package/dist/tools/assets.js.map +1 -0
- package/dist/tools/batch-operations.d.ts +125 -0
- package/dist/tools/batch-operations.d.ts.map +1 -0
- package/dist/tools/batch-operations.js +207 -0
- package/dist/tools/batch-operations.js.map +1 -0
- package/dist/tools/clients.d.ts +145 -0
- package/dist/tools/clients.d.ts.map +1 -0
- package/dist/tools/clients.js +148 -0
- package/dist/tools/clients.js.map +1 -0
- package/dist/tools/reference-data.d.ts +118 -0
- package/dist/tools/reference-data.d.ts.map +1 -0
- package/dist/tools/reference-data.js +103 -0
- package/dist/tools/reference-data.js.map +1 -0
- package/dist/tools/registrations.d.ts +7 -0
- package/dist/tools/registrations.d.ts.map +1 -0
- package/dist/tools/registrations.js +61 -0
- package/dist/tools/registrations.js.map +1 -0
- package/dist/tools/registry.d.ts +67 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +71 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/sites.d.ts +188 -0
- package/dist/tools/sites.d.ts.map +1 -0
- package/dist/tools/sites.js +258 -0
- package/dist/tools/sites.js.map +1 -0
- package/dist/tools/users.d.ts +317 -0
- package/dist/tools/users.d.ts.map +1 -0
- package/dist/tools/users.js +489 -0
- package/dist/tools/users.js.map +1 -0
- package/dist/types/halopsa.d.ts +212 -0
- package/dist/types/halopsa.d.ts.map +1 -0
- package/dist/types/halopsa.js +8 -0
- package/dist/types/halopsa.js.map +1 -0
- package/dist/utils/formatter.d.ts +18 -0
- package/dist/utils/formatter.d.ts.map +1 -0
- package/dist/utils/formatter.js +178 -0
- package/dist/utils/formatter.js.map +1 -0
- package/dist/utils/similarity.d.ts +25 -0
- package/dist/utils/similarity.d.ts.map +1 -0
- package/dist/utils/similarity.js +90 -0
- package/dist/utils/similarity.js.map +1 -0
- package/dist/utils/zod-to-schema.d.ts +29 -0
- package/dist/utils/zod-to-schema.d.ts.map +1 -0
- package/dist/utils/zod-to-schema.js +182 -0
- package/dist/utils/zod-to-schema.js.map +1 -0
- package/package.json +61 -0
package/.env.example
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# HaloPSA MCP Server Environment Configuration
|
|
2
|
+
# Copy this file to .env and fill in your actual values
|
|
3
|
+
# NEVER commit the .env file to git
|
|
4
|
+
|
|
5
|
+
# HaloPSA OAuth2 Client ID (required)
|
|
6
|
+
# Get from: HaloPSA > Configuration > Integrations > API Applications
|
|
7
|
+
HALOPSA_CLIENT_ID=your-client-id-here
|
|
8
|
+
|
|
9
|
+
# HaloPSA OAuth2 Client Secret (required)
|
|
10
|
+
HALOPSA_CLIENT_SECRET=your-client-secret-here
|
|
11
|
+
|
|
12
|
+
# HaloPSA Base URL (optional)
|
|
13
|
+
# Default: https://support.wilnertech.com
|
|
14
|
+
# Change if using a different HaloPSA instance
|
|
15
|
+
HALOPSA_BASE_URL=https://support.wilnertech.com
|
|
16
|
+
|
|
17
|
+
# Test Client ID (required for smoke tests only)
|
|
18
|
+
# A client ID to scope test resource creation/cleanup
|
|
19
|
+
# HALOPSA_TEST_CLIENT_ID=123
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 WilnerTech
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# HaloPSA MCP Server
|
|
2
|
+
|
|
3
|
+
Model Context Protocol server for HaloPSA API integration - WilnerTech billing backbone.
|
|
4
|
+
|
|
5
|
+
## Critical Business Context
|
|
6
|
+
|
|
7
|
+
**HaloPSA is WilnerTech's billing backbone.** Asset and user counts are automatically pulled from HaloPSA for billing calculations.
|
|
8
|
+
|
|
9
|
+
- **Accurate data = Accurate billing = Revenue protection**
|
|
10
|
+
- **Duplicates = Billing errors = Revenue loss**
|
|
11
|
+
- **Only ACTIVE assets (status_id=1) count toward billing**
|
|
12
|
+
- **Only ACTIVE users (inactive=false) count toward billing**
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- 31 tools for managing HaloPSA data
|
|
17
|
+
- OAuth2 Client Credentials authentication with automatic token refresh
|
|
18
|
+
- Rate limiting with exponential backoff (60 requests/minute)
|
|
19
|
+
- In-memory caching with configurable TTLs
|
|
20
|
+
- Token optimization via format_options (compact/standard/detailed modes)
|
|
21
|
+
- Deduplication tools with confidence scoring
|
|
22
|
+
- Custom field inspection for assets
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cd .claude/mcp-servers/halopsa
|
|
28
|
+
npm install
|
|
29
|
+
npm run build
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
### HaloPSA API Agent Setup (Security Best Practice)
|
|
35
|
+
|
|
36
|
+
**WilnerTech uses a dedicated API-only agent for Claude MCP integration** following HaloPSA security best practices:
|
|
37
|
+
|
|
38
|
+
1. **Create Dedicated Agent in HaloPSA:**
|
|
39
|
+
- Navigate to: Configuration > Agents & Teams > Agents
|
|
40
|
+
- Create new agent: `claude_mcp` (API-only account)
|
|
41
|
+
- Agent Type: API Only
|
|
42
|
+
- Status: Active
|
|
43
|
+
- Generate OAuth2 Client Credentials
|
|
44
|
+
|
|
45
|
+
2. **Security Benefits:**
|
|
46
|
+
- **Audit Trail:** All API actions appear under `claude_mcp` agent in HaloPSA audit logs
|
|
47
|
+
- **Principle of Least Privilege:** Grant only necessary permissions (read-only for Phase 1)
|
|
48
|
+
- **Easy Revocation:** Disable `claude_mcp` agent to immediately terminate MCP access
|
|
49
|
+
- **No Personal Credential Exposure:** Keeps personal HaloPSA accounts separate from automation
|
|
50
|
+
|
|
51
|
+
3. **Configure Permissions (Minimal Approach):**
|
|
52
|
+
|
|
53
|
+
**Phase 1 - Read-Only Inspection (✅ Current):**
|
|
54
|
+
- `read:assets` - Asset inspection, custom field analysis, deduplication
|
|
55
|
+
- `read:customers` - User/site/client inspection and deduplication (includes sites and clients)
|
|
56
|
+
|
|
57
|
+
**What Works with Phase 1 Permissions:**
|
|
58
|
+
- ✅ All asset inspection tools (list, search, get, batch)
|
|
59
|
+
- ✅ Asset deduplication tools (find matches, scan duplicates)
|
|
60
|
+
- ✅ Custom field analysis tools
|
|
61
|
+
- ✅ All user/site/client inspection tools
|
|
62
|
+
- ✅ User/site deduplication tools
|
|
63
|
+
- ✅ Reference data tools (asset types, statuses)
|
|
64
|
+
|
|
65
|
+
**Phase 2 - Write Operations (When Ready):**
|
|
66
|
+
- `edit:assets` - Enables create/update/delete asset operations (3 tools)
|
|
67
|
+
- `edit:customers` - Enables create/update user/site operations (4 tools)
|
|
68
|
+
|
|
69
|
+
**Note:** Start minimal and expand permissions as needed. All 31 tools are implemented; write operations will return permission errors until Phase 2 permissions are granted.
|
|
70
|
+
|
|
71
|
+
### Environment Variables
|
|
72
|
+
|
|
73
|
+
| Variable | Required | Description |
|
|
74
|
+
|----------|----------|-------------|
|
|
75
|
+
| `HALOPSA_CLIENT_ID` | Yes | OAuth2 Client ID from `claude_mcp` agent |
|
|
76
|
+
| `HALOPSA_CLIENT_SECRET` | Yes | OAuth2 Client Secret from `claude_mcp` agent |
|
|
77
|
+
| `HALOPSA_BASE_URL` | No | API base URL (default: `https://support.wilnertech.com`) |
|
|
78
|
+
|
|
79
|
+
**Credential Storage:** Store credentials in ITGlue under:
|
|
80
|
+
- Location: `Wilner Tech Org > 06 - Authentication - Encryption Keys`
|
|
81
|
+
- Name: `API - HaloPSA - claude_mcp Agent`
|
|
82
|
+
- Fields:
|
|
83
|
+
- Username: OAuth2 Client ID
|
|
84
|
+
- Password: OAuth2 Client Secret
|
|
85
|
+
- URL: `https://support.wilnertech.com`
|
|
86
|
+
|
|
87
|
+
### Claude Desktop Configuration
|
|
88
|
+
|
|
89
|
+
Add to `%APPDATA%\Claude\claude_desktop_config.json` (Windows) or `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS):
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"mcpServers": {
|
|
94
|
+
"halopsa": {
|
|
95
|
+
"command": "node",
|
|
96
|
+
"args": ["C:/Users/ywilner/OneDrive - Wilner Tech Inc/Documents/WTECH - Script Repository/.claude/mcp-servers/halopsa/dist/index.js"],
|
|
97
|
+
"env": {
|
|
98
|
+
"HALOPSA_CLIENT_ID": "claude_mcp_client_id_here",
|
|
99
|
+
"HALOPSA_CLIENT_SECRET": "claude_mcp_client_secret_here",
|
|
100
|
+
"HALOPSA_BASE_URL": "https://support.wilnertech.com"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Security Note:** These credentials are for the dedicated `claude_mcp` agent, not personal credentials. All actions are audited under the `claude_mcp` agent in HaloPSA.
|
|
108
|
+
|
|
109
|
+
## Tools Reference
|
|
110
|
+
|
|
111
|
+
### Asset Tools (11)
|
|
112
|
+
|
|
113
|
+
| Tool | Description |
|
|
114
|
+
|------|-------------|
|
|
115
|
+
| `list_halopsa_assets` | List assets with filtering, caching (1min TTL) |
|
|
116
|
+
| `get_halopsa_asset` | Get single asset with custom fields |
|
|
117
|
+
| `search_halopsa_assets_by_serial` | Search by serial number (key_field) |
|
|
118
|
+
| `search_halopsa_assets_by_hostname` | Search by hostname (key_field2) |
|
|
119
|
+
| `create_halopsa_asset` | Create new asset |
|
|
120
|
+
| `update_halopsa_asset` | Update existing asset |
|
|
121
|
+
| `delete_halopsa_asset` | Delete asset |
|
|
122
|
+
| `list_halopsa_asset_custom_fields` | Inspect custom fields for an asset |
|
|
123
|
+
| `get_halopsa_asset_field_schema` | Analyze custom field definitions |
|
|
124
|
+
| `find_halopsa_asset_match` | Deduplication with confidence scoring |
|
|
125
|
+
| `scan_halopsa_asset_duplicates` | Scan for duplicate assets |
|
|
126
|
+
|
|
127
|
+
### User Tools (7)
|
|
128
|
+
|
|
129
|
+
| Tool | Description |
|
|
130
|
+
|------|-------------|
|
|
131
|
+
| `list_halopsa_users` | List users with filtering, caching (1min TTL) |
|
|
132
|
+
| `get_halopsa_user` | Get single user with custom fields |
|
|
133
|
+
| `search_halopsa_users_by_email` | Search by email address |
|
|
134
|
+
| `create_halopsa_user` | Create new user |
|
|
135
|
+
| `update_halopsa_user` | Update existing user |
|
|
136
|
+
| `find_halopsa_user_match` | Deduplication with confidence scoring |
|
|
137
|
+
| `scan_halopsa_user_duplicates` | Scan for duplicate users |
|
|
138
|
+
|
|
139
|
+
### Site Tools (4)
|
|
140
|
+
|
|
141
|
+
| Tool | Description |
|
|
142
|
+
|------|-------------|
|
|
143
|
+
| `list_halopsa_sites` | List sites with caching (5min TTL) |
|
|
144
|
+
| `get_halopsa_site` | Get single site |
|
|
145
|
+
| `create_halopsa_site` | Create new site |
|
|
146
|
+
| `find_halopsa_site_match` | Deduplication with fuzzy matching |
|
|
147
|
+
|
|
148
|
+
### Client Tools (3)
|
|
149
|
+
|
|
150
|
+
| Tool | Description |
|
|
151
|
+
|------|-------------|
|
|
152
|
+
| `list_halopsa_clients` | List clients with caching (5min TTL) |
|
|
153
|
+
| `get_halopsa_client` | Get single client |
|
|
154
|
+
| `search_halopsa_clients` | Search clients by name |
|
|
155
|
+
|
|
156
|
+
### Reference Data Tools (3)
|
|
157
|
+
|
|
158
|
+
| Tool | Description |
|
|
159
|
+
|------|-------------|
|
|
160
|
+
| `list_halopsa_asset_types` | List asset types (1hr cache) |
|
|
161
|
+
| `list_halopsa_asset_statuses` | List statuses with billing notes |
|
|
162
|
+
| `list_halopsa_contact_types` | List contact types (1hr cache) |
|
|
163
|
+
|
|
164
|
+
### Batch Operations Tools (3)
|
|
165
|
+
|
|
166
|
+
| Tool | Description |
|
|
167
|
+
|------|-------------|
|
|
168
|
+
| `get_halopsa_assets_batch` | Get multiple assets (max 50) |
|
|
169
|
+
| `get_halopsa_users_batch` | Get multiple users (max 50) |
|
|
170
|
+
| `get_halopsa_sites_batch` | Get multiple sites (max 50) |
|
|
171
|
+
|
|
172
|
+
## Deduplication Algorithms
|
|
173
|
+
|
|
174
|
+
### Asset Deduplication
|
|
175
|
+
|
|
176
|
+
| Match Type | Confidence | Action |
|
|
177
|
+
|------------|------------|--------|
|
|
178
|
+
| Exact serial number | 100% | Auto-update |
|
|
179
|
+
| Hostname only (serial mismatch) | 85% | Manual review |
|
|
180
|
+
| No match | 0% | Create new |
|
|
181
|
+
|
|
182
|
+
### User Deduplication
|
|
183
|
+
|
|
184
|
+
| Match Type | Confidence | Action |
|
|
185
|
+
|------------|------------|--------|
|
|
186
|
+
| Exact email | 100% | Auto-update |
|
|
187
|
+
| UPN match (Entra) | 95% | Auto-update |
|
|
188
|
+
| Username only | 70% | Manual review |
|
|
189
|
+
| No match | 0% | Create new |
|
|
190
|
+
|
|
191
|
+
### Site Deduplication
|
|
192
|
+
|
|
193
|
+
| Match Type | Confidence | Action |
|
|
194
|
+
|------------|------------|--------|
|
|
195
|
+
| Exact name | 100% | Auto-update |
|
|
196
|
+
| Fuzzy name + address | 95% | Auto-update |
|
|
197
|
+
| Fuzzy name only | 85% | Manual review |
|
|
198
|
+
| No match | 0% | Create new |
|
|
199
|
+
|
|
200
|
+
## Caching TTLs
|
|
201
|
+
|
|
202
|
+
| Data Type | TTL | Rationale |
|
|
203
|
+
|-----------|-----|-----------|
|
|
204
|
+
| Assets | 1 min | Billing-critical, frequent changes |
|
|
205
|
+
| Users | 1 min | Billing-critical, frequent changes |
|
|
206
|
+
| Sites | 5 min | Moderate change rate |
|
|
207
|
+
| Clients | 5 min | Moderate change rate |
|
|
208
|
+
| Reference data | 1 hour | Rarely changes |
|
|
209
|
+
|
|
210
|
+
## Token Optimization
|
|
211
|
+
|
|
212
|
+
All tools support `format_options` for reducing response size:
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
{
|
|
216
|
+
"format_options": {
|
|
217
|
+
"format": "compact",
|
|
218
|
+
"fields": ["id", "name", "status_id"],
|
|
219
|
+
"omit_empty": true
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Formats:**
|
|
225
|
+
- `compact`: Minimal fields (id, name, status)
|
|
226
|
+
- `standard`: Default fields (no pretty-printing)
|
|
227
|
+
- `detailed`: All fields with pretty-printing
|
|
228
|
+
|
|
229
|
+
## Rate Limiting
|
|
230
|
+
|
|
231
|
+
- **Limit:** 60 requests/minute per client_id
|
|
232
|
+
- **Auto-throttling:** 1 second minimum between requests
|
|
233
|
+
- **Retry:** Exponential backoff on 429 errors (1s, 2s, 4s)
|
|
234
|
+
- **Token refresh:** 5 minutes before expiration
|
|
235
|
+
|
|
236
|
+
## Error Handling
|
|
237
|
+
|
|
238
|
+
The server provides detailed error responses:
|
|
239
|
+
|
|
240
|
+
```json
|
|
241
|
+
{
|
|
242
|
+
"error": true,
|
|
243
|
+
"error_type": "RateLimitExceeded",
|
|
244
|
+
"message": "HaloPSA API rate limit exceeded",
|
|
245
|
+
"details": {
|
|
246
|
+
"retry_after_seconds": 30
|
|
247
|
+
},
|
|
248
|
+
"suggestion": "Wait 30 seconds before retrying."
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Development
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
# Build
|
|
256
|
+
npm run build
|
|
257
|
+
|
|
258
|
+
# Watch mode
|
|
259
|
+
npm run dev
|
|
260
|
+
|
|
261
|
+
# Start server
|
|
262
|
+
npm start
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Related Documentation
|
|
266
|
+
|
|
267
|
+
- [HaloPSA API Documentation](/.claude/api-docs/halopsa.md)
|
|
268
|
+
- [HaloPSA API Agent](/.claude/agents/halopsa-api-agent.md)
|
|
269
|
+
- [ITGlue MCP Server](/.claude/mcp-servers/itglue/) (reference implementation)
|
|
270
|
+
- [Official HaloPSA API Docs](https://halo.haloservicedesk.com/apidoc/index.html)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HaloPSA API Client with OAuth2 authentication, rate limiting, and caching
|
|
3
|
+
* Implements exponential backoff for rate limits and auto-refresh for tokens
|
|
4
|
+
*/
|
|
5
|
+
export interface HaloPSAClientConfig {
|
|
6
|
+
clientId: string;
|
|
7
|
+
clientSecret: string;
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
maxRetries?: number;
|
|
10
|
+
retryDelay?: number;
|
|
11
|
+
}
|
|
12
|
+
interface CacheOptions {
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
ttl?: number;
|
|
15
|
+
keyPrefix?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare class HaloPSAAPIClient {
|
|
18
|
+
private client;
|
|
19
|
+
private authClient;
|
|
20
|
+
private clientId;
|
|
21
|
+
private clientSecret;
|
|
22
|
+
private baseUrl;
|
|
23
|
+
private maxRetries;
|
|
24
|
+
private retryDelay;
|
|
25
|
+
private accessToken;
|
|
26
|
+
private tokenExpiresAt;
|
|
27
|
+
private tokenRefreshBuffer;
|
|
28
|
+
private requestCount;
|
|
29
|
+
private lastRequestTime;
|
|
30
|
+
private readonly MIN_REQUEST_INTERVAL;
|
|
31
|
+
private inflight;
|
|
32
|
+
constructor(config: HaloPSAClientConfig);
|
|
33
|
+
/**
|
|
34
|
+
* Ensure we have a valid access token
|
|
35
|
+
* Requests new token if none exists or if close to expiration
|
|
36
|
+
*/
|
|
37
|
+
private ensureValidToken;
|
|
38
|
+
/**
|
|
39
|
+
* Request a new access token from HaloPSA
|
|
40
|
+
*/
|
|
41
|
+
private refreshToken;
|
|
42
|
+
/**
|
|
43
|
+
* Throttle requests to respect rate limits (60 requests/minute)
|
|
44
|
+
* Updates lastRequestTime BEFORE delay to prevent race conditions with concurrent requests
|
|
45
|
+
*/
|
|
46
|
+
private throttleRequest;
|
|
47
|
+
/**
|
|
48
|
+
* Handle API errors with retry logic for rate limits and token expiration
|
|
49
|
+
*/
|
|
50
|
+
private handleError;
|
|
51
|
+
/**
|
|
52
|
+
* Make a GET request with retry logic
|
|
53
|
+
*/
|
|
54
|
+
get<T>(endpoint: string, params?: Record<string, unknown>, retryCount?: number): Promise<T>;
|
|
55
|
+
/**
|
|
56
|
+
* Make a GET request with caching support
|
|
57
|
+
*/
|
|
58
|
+
getCached<T>(endpoint: string, params?: Record<string, unknown>, cacheOptions?: CacheOptions): Promise<T>;
|
|
59
|
+
/**
|
|
60
|
+
* Invalidate cache for specific patterns
|
|
61
|
+
*/
|
|
62
|
+
invalidateCache(pattern: string): number;
|
|
63
|
+
/**
|
|
64
|
+
* Make a POST request with retry logic
|
|
65
|
+
*/
|
|
66
|
+
post<T>(endpoint: string, data: unknown, retryCount?: number): Promise<T>;
|
|
67
|
+
/**
|
|
68
|
+
* Make a DELETE request with retry logic
|
|
69
|
+
*/
|
|
70
|
+
delete<T>(endpoint: string, retryCount?: number): Promise<T>;
|
|
71
|
+
/**
|
|
72
|
+
* Check if error is retryable
|
|
73
|
+
*/
|
|
74
|
+
private isRetryableError;
|
|
75
|
+
/**
|
|
76
|
+
* Get request statistics
|
|
77
|
+
*/
|
|
78
|
+
getStats(): {
|
|
79
|
+
requestCount: number;
|
|
80
|
+
lastRequestTime: number;
|
|
81
|
+
tokenExpiresAt: number;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export {};
|
|
85
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAWH,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AASD,UAAU,YAAY;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,UAAU,CAAgB;IAClC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,UAAU,CAAS;IAG3B,OAAO,CAAC,WAAW,CAAuB;IAC1C,OAAO,CAAC,cAAc,CAAa;IACnC,OAAO,CAAC,kBAAkB,CAAyB;IAGnD,OAAO,CAAC,YAAY,CAAa;IACjC,OAAO,CAAC,eAAe,CAAa;IACpC,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAQ;IAG7C,OAAO,CAAC,QAAQ,CAA4C;gBAEhD,MAAM,EAAE,mBAAmB;IA+CvC;;;OAGG;YACW,gBAAgB;IAS9B;;OAEG;YACW,YAAY;IA0C1B;;;OAGG;YACW,eAAe;IAiB7B;;OAEG;YACW,WAAW;IAsDzB;;OAEG;IACG,GAAG,CAAC,CAAC,EACT,QAAQ,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,UAAU,SAAI,GACb,OAAO,CAAC,CAAC,CAAC;IAiBb;;OAEG;IACG,SAAS,CAAC,CAAC,EACf,QAAQ,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,YAAY,CAAC,EAAE,YAAY,GAC1B,OAAO,CAAC,CAAC,CAAC;IAqCb;;OAEG;IACH,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM;IAIxC;;OAEG;IACG,IAAI,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,UAAU,SAAI,GAAG,OAAO,CAAC,CAAC,CAAC;IAiB1E;;OAEG;IACG,MAAM,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,SAAI,GAAG,OAAO,CAAC,CAAC,CAAC;IAiB7D;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAiBxB;;OAEG;IACH,QAAQ,IAAI;QACV,YAAY,EAAE,MAAM,CAAC;QACrB,eAAe,EAAE,MAAM,CAAC;QACxB,cAAc,EAAE,MAAM,CAAC;KACxB;CAOF"}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HaloPSA API Client with OAuth2 authentication, rate limiting, and caching
|
|
3
|
+
* Implements exponential backoff for rate limits and auto-refresh for tokens
|
|
4
|
+
*/
|
|
5
|
+
import axios from 'axios';
|
|
6
|
+
import { globalCache, generateCacheKey, TTL } from '../cache/memory-cache.js';
|
|
7
|
+
import { parseHaloPSAApiError, createNetworkError, createAuthError, HaloPSAError, } from './errors.js';
|
|
8
|
+
export class HaloPSAAPIClient {
|
|
9
|
+
client;
|
|
10
|
+
authClient;
|
|
11
|
+
clientId;
|
|
12
|
+
clientSecret;
|
|
13
|
+
baseUrl;
|
|
14
|
+
maxRetries;
|
|
15
|
+
retryDelay;
|
|
16
|
+
// Token management
|
|
17
|
+
accessToken = null;
|
|
18
|
+
tokenExpiresAt = 0;
|
|
19
|
+
tokenRefreshBuffer = 2 * 60 * 1000; // Refresh 2 minutes before expiration
|
|
20
|
+
// Rate limiting
|
|
21
|
+
requestCount = 0;
|
|
22
|
+
lastRequestTime = 0;
|
|
23
|
+
MIN_REQUEST_INTERVAL = 1000; // 1 second between requests (60/min limit)
|
|
24
|
+
// Inflight request deduplication
|
|
25
|
+
inflight = new Map();
|
|
26
|
+
constructor(config) {
|
|
27
|
+
this.clientId = config.clientId;
|
|
28
|
+
this.clientSecret = config.clientSecret;
|
|
29
|
+
this.baseUrl = config.baseUrl;
|
|
30
|
+
this.maxRetries = config.maxRetries ?? 3;
|
|
31
|
+
this.retryDelay = config.retryDelay ?? 1000;
|
|
32
|
+
// Create auth client (for token requests)
|
|
33
|
+
// Auth server is at: baseUrl/auth (e.g., https://support.wilnertech.com/auth)
|
|
34
|
+
this.authClient = axios.create({
|
|
35
|
+
baseURL: `${this.baseUrl}/auth`,
|
|
36
|
+
timeout: 30000,
|
|
37
|
+
});
|
|
38
|
+
// Create API client (for data requests)
|
|
39
|
+
// Resource server is at: baseUrl/api (e.g., https://support.wilnertech.com/api)
|
|
40
|
+
this.client = axios.create({
|
|
41
|
+
baseURL: `${this.baseUrl}/api`,
|
|
42
|
+
headers: {
|
|
43
|
+
'Content-Type': 'application/json',
|
|
44
|
+
},
|
|
45
|
+
timeout: 30000,
|
|
46
|
+
});
|
|
47
|
+
// Add request interceptor for authentication and rate limiting
|
|
48
|
+
this.client.interceptors.request.use(async (config) => {
|
|
49
|
+
// Ensure we have a valid token
|
|
50
|
+
await this.ensureValidToken();
|
|
51
|
+
// Add authorization header
|
|
52
|
+
config.headers.Authorization = `Bearer ${this.accessToken}`;
|
|
53
|
+
// Throttle requests to respect rate limits
|
|
54
|
+
await this.throttleRequest();
|
|
55
|
+
return config;
|
|
56
|
+
});
|
|
57
|
+
// Add response interceptor for error handling
|
|
58
|
+
this.client.interceptors.response.use((response) => response, async (error) => {
|
|
59
|
+
return this.handleError(error);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Ensure we have a valid access token
|
|
64
|
+
* Requests new token if none exists or if close to expiration
|
|
65
|
+
*/
|
|
66
|
+
async ensureValidToken() {
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
// Check if token is missing or expired (with buffer)
|
|
69
|
+
if (!this.accessToken || now >= this.tokenExpiresAt - this.tokenRefreshBuffer) {
|
|
70
|
+
await this.refreshToken();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Request a new access token from HaloPSA
|
|
75
|
+
*/
|
|
76
|
+
async refreshToken() {
|
|
77
|
+
try {
|
|
78
|
+
const params = new URLSearchParams();
|
|
79
|
+
params.append('grant_type', 'client_credentials');
|
|
80
|
+
params.append('client_id', this.clientId);
|
|
81
|
+
params.append('client_secret', this.clientSecret);
|
|
82
|
+
params.append('scope', 'all');
|
|
83
|
+
const response = await this.authClient.post('/token', params, {
|
|
84
|
+
headers: {
|
|
85
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
this.accessToken = response.data.access_token;
|
|
89
|
+
// Calculate expiration time in milliseconds
|
|
90
|
+
this.tokenExpiresAt = Date.now() + response.data.expires_in * 1000;
|
|
91
|
+
console.error(`HaloPSA: New access token obtained. Expires at: ${new Date(this.tokenExpiresAt).toISOString()}`);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (axios.isAxiosError(error)) {
|
|
95
|
+
console.error('HaloPSA: Token refresh failed');
|
|
96
|
+
console.error(`HaloPSA: Request URL: ${error.config?.url}`);
|
|
97
|
+
console.error(`HaloPSA: Base URL: ${error.config?.baseURL}`);
|
|
98
|
+
console.error(`HaloPSA: Status: ${error.response?.status}`);
|
|
99
|
+
console.error(`HaloPSA: Response data:`, error.response?.data);
|
|
100
|
+
const message = error.response?.data?.error_description ||
|
|
101
|
+
error.response?.data?.error ||
|
|
102
|
+
error.message;
|
|
103
|
+
throw createAuthError(`Failed to obtain access token: ${message}`);
|
|
104
|
+
}
|
|
105
|
+
throw createAuthError('Failed to obtain access token');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Throttle requests to respect rate limits (60 requests/minute)
|
|
110
|
+
* Updates lastRequestTime BEFORE delay to prevent race conditions with concurrent requests
|
|
111
|
+
*/
|
|
112
|
+
async throttleRequest() {
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
115
|
+
if (timeSinceLastRequest < this.MIN_REQUEST_INTERVAL) {
|
|
116
|
+
const delay = this.MIN_REQUEST_INTERVAL - timeSinceLastRequest;
|
|
117
|
+
// Update lastRequestTime BEFORE delay to prevent concurrent requests from calculating
|
|
118
|
+
// the same delay and firing simultaneously after waiting
|
|
119
|
+
this.lastRequestTime = now + delay;
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
this.lastRequestTime = now;
|
|
124
|
+
}
|
|
125
|
+
this.requestCount++;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Handle API errors with retry logic for rate limits and token expiration
|
|
129
|
+
*/
|
|
130
|
+
async handleError(error) {
|
|
131
|
+
// If error is already a HaloPSAError (e.g., from request interceptor), re-throw as-is
|
|
132
|
+
if (error instanceof HaloPSAError) {
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
// Network error (no response from server)
|
|
136
|
+
if (!error.response) {
|
|
137
|
+
throw createNetworkError(error);
|
|
138
|
+
}
|
|
139
|
+
const status = error.response.status;
|
|
140
|
+
const data = error.response.data;
|
|
141
|
+
const endpoint = error.config?.url || 'unknown';
|
|
142
|
+
// Handle 401 - token may be expired
|
|
143
|
+
if (status === 401) {
|
|
144
|
+
// Clear token and retry once
|
|
145
|
+
this.accessToken = null;
|
|
146
|
+
this.tokenExpiresAt = 0;
|
|
147
|
+
// Don't retry if this was already a retry
|
|
148
|
+
if (error.config && !error.config.headers?.['X-Retry-Auth']) {
|
|
149
|
+
try {
|
|
150
|
+
await this.ensureValidToken();
|
|
151
|
+
error.config.headers = error.config.headers || {};
|
|
152
|
+
error.config.headers['X-Retry-Auth'] = 'true';
|
|
153
|
+
error.config.headers['Authorization'] = `Bearer ${this.accessToken}`;
|
|
154
|
+
return this.client.request(error.config);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Token refresh failed, throw original error
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Handle 429 - rate limited
|
|
162
|
+
if (status === 429) {
|
|
163
|
+
const retryAfterHeader = error.response.headers['retry-after'];
|
|
164
|
+
const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) : 30;
|
|
165
|
+
console.warn(`HaloPSA: Rate limited. Waiting ${retryAfter} seconds before retry...`);
|
|
166
|
+
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
|
|
167
|
+
// Retry the request
|
|
168
|
+
if (error.config) {
|
|
169
|
+
return this.client.request(error.config);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Parse and throw enhanced error
|
|
173
|
+
const enhancedError = parseHaloPSAApiError(status, data, endpoint);
|
|
174
|
+
throw enhancedError;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Make a GET request with retry logic
|
|
178
|
+
*/
|
|
179
|
+
async get(endpoint, params, retryCount = 0) {
|
|
180
|
+
try {
|
|
181
|
+
const response = await this.client.get(endpoint, { params });
|
|
182
|
+
return response.data;
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (retryCount < this.maxRetries && this.isRetryableError(error)) {
|
|
186
|
+
const delay = this.retryDelay * Math.pow(2, retryCount);
|
|
187
|
+
console.warn(`HaloPSA: Request failed. Retrying in ${delay}ms (attempt ${retryCount + 1}/${this.maxRetries})...`);
|
|
188
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
189
|
+
return this.get(endpoint, params, retryCount + 1);
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Make a GET request with caching support
|
|
196
|
+
*/
|
|
197
|
+
async getCached(endpoint, params, cacheOptions) {
|
|
198
|
+
const { enabled = true, ttl = TTL.ASSET_LIST, keyPrefix = endpoint } = cacheOptions || {};
|
|
199
|
+
// Skip cache if disabled
|
|
200
|
+
if (!enabled) {
|
|
201
|
+
return this.get(endpoint, params);
|
|
202
|
+
}
|
|
203
|
+
// Generate cache key
|
|
204
|
+
const cacheKey = generateCacheKey(keyPrefix, params || {});
|
|
205
|
+
// Check cache first
|
|
206
|
+
const cachedValue = globalCache.get(cacheKey);
|
|
207
|
+
if (cachedValue !== null) {
|
|
208
|
+
return cachedValue;
|
|
209
|
+
}
|
|
210
|
+
// Inflight dedup — if an identical request is already in progress, share it
|
|
211
|
+
const existing = this.inflight.get(cacheKey);
|
|
212
|
+
if (existing) {
|
|
213
|
+
return existing;
|
|
214
|
+
}
|
|
215
|
+
// Fetch from API, cache result, and deduplicate concurrent requests
|
|
216
|
+
const promise = this.get(endpoint, params)
|
|
217
|
+
.then((result) => {
|
|
218
|
+
globalCache.set(cacheKey, result, ttl);
|
|
219
|
+
return result;
|
|
220
|
+
})
|
|
221
|
+
.finally(() => {
|
|
222
|
+
this.inflight.delete(cacheKey);
|
|
223
|
+
});
|
|
224
|
+
this.inflight.set(cacheKey, promise);
|
|
225
|
+
return promise;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Invalidate cache for specific patterns
|
|
229
|
+
*/
|
|
230
|
+
invalidateCache(pattern) {
|
|
231
|
+
return globalCache.invalidatePattern(pattern);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Make a POST request with retry logic
|
|
235
|
+
*/
|
|
236
|
+
async post(endpoint, data, retryCount = 0) {
|
|
237
|
+
try {
|
|
238
|
+
const response = await this.client.post(endpoint, data);
|
|
239
|
+
return response.data;
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
if (retryCount < this.maxRetries && this.isRetryableError(error)) {
|
|
243
|
+
const delay = this.retryDelay * Math.pow(2, retryCount);
|
|
244
|
+
console.warn(`HaloPSA: Request failed. Retrying in ${delay}ms (attempt ${retryCount + 1}/${this.maxRetries})...`);
|
|
245
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
246
|
+
return this.post(endpoint, data, retryCount + 1);
|
|
247
|
+
}
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Make a DELETE request with retry logic
|
|
253
|
+
*/
|
|
254
|
+
async delete(endpoint, retryCount = 0) {
|
|
255
|
+
try {
|
|
256
|
+
const response = await this.client.delete(endpoint);
|
|
257
|
+
return response.data;
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
if (retryCount < this.maxRetries && this.isRetryableError(error)) {
|
|
261
|
+
const delay = this.retryDelay * Math.pow(2, retryCount);
|
|
262
|
+
console.warn(`HaloPSA: Request failed. Retrying in ${delay}ms (attempt ${retryCount + 1}/${this.maxRetries})...`);
|
|
263
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
264
|
+
return this.delete(endpoint, retryCount + 1);
|
|
265
|
+
}
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Check if error is retryable
|
|
271
|
+
*/
|
|
272
|
+
isRetryableError(error) {
|
|
273
|
+
if (error instanceof HaloPSAError) {
|
|
274
|
+
// Retry on rate limit and server errors
|
|
275
|
+
return (error.errorType === 'RateLimitExceeded' ||
|
|
276
|
+
error.errorType === 'ServerError' ||
|
|
277
|
+
error.errorType === 'NetworkError');
|
|
278
|
+
}
|
|
279
|
+
if (axios.isAxiosError(error)) {
|
|
280
|
+
const status = error.response?.status;
|
|
281
|
+
// Retry on 429 (rate limit), 500, 502, 503, 504 (server errors)
|
|
282
|
+
return status === 429 || (status !== undefined && status >= 500 && status <= 504);
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Get request statistics
|
|
288
|
+
*/
|
|
289
|
+
getStats() {
|
|
290
|
+
return {
|
|
291
|
+
requestCount: this.requestCount,
|
|
292
|
+
lastRequestTime: this.lastRequestTime,
|
|
293
|
+
tokenExpiresAt: this.tokenExpiresAt,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
//# sourceMappingURL=client.js.map
|