@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.
Files changed (76) hide show
  1. package/.env.example +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +270 -0
  4. package/dist/api/client.d.ts +85 -0
  5. package/dist/api/client.d.ts.map +1 -0
  6. package/dist/api/client.js +297 -0
  7. package/dist/api/client.js.map +1 -0
  8. package/dist/api/errors.d.ts +60 -0
  9. package/dist/api/errors.d.ts.map +1 -0
  10. package/dist/api/errors.js +188 -0
  11. package/dist/api/errors.js.map +1 -0
  12. package/dist/cache/memory-cache.d.ts +89 -0
  13. package/dist/cache/memory-cache.d.ts.map +1 -0
  14. package/dist/cache/memory-cache.js +175 -0
  15. package/dist/cache/memory-cache.js.map +1 -0
  16. package/dist/cache/prewarm.d.ts +12 -0
  17. package/dist/cache/prewarm.d.ts.map +1 -0
  18. package/dist/cache/prewarm.js +55 -0
  19. package/dist/cache/prewarm.js.map +1 -0
  20. package/dist/index.d.ts +12 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +141 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/schemas/common.d.ts +212 -0
  25. package/dist/schemas/common.d.ts.map +1 -0
  26. package/dist/schemas/common.js +127 -0
  27. package/dist/schemas/common.js.map +1 -0
  28. package/dist/tools/assets.d.ts +482 -0
  29. package/dist/tools/assets.d.ts.map +1 -0
  30. package/dist/tools/assets.js +732 -0
  31. package/dist/tools/assets.js.map +1 -0
  32. package/dist/tools/batch-operations.d.ts +125 -0
  33. package/dist/tools/batch-operations.d.ts.map +1 -0
  34. package/dist/tools/batch-operations.js +207 -0
  35. package/dist/tools/batch-operations.js.map +1 -0
  36. package/dist/tools/clients.d.ts +145 -0
  37. package/dist/tools/clients.d.ts.map +1 -0
  38. package/dist/tools/clients.js +148 -0
  39. package/dist/tools/clients.js.map +1 -0
  40. package/dist/tools/reference-data.d.ts +118 -0
  41. package/dist/tools/reference-data.d.ts.map +1 -0
  42. package/dist/tools/reference-data.js +103 -0
  43. package/dist/tools/reference-data.js.map +1 -0
  44. package/dist/tools/registrations.d.ts +7 -0
  45. package/dist/tools/registrations.d.ts.map +1 -0
  46. package/dist/tools/registrations.js +61 -0
  47. package/dist/tools/registrations.js.map +1 -0
  48. package/dist/tools/registry.d.ts +67 -0
  49. package/dist/tools/registry.d.ts.map +1 -0
  50. package/dist/tools/registry.js +71 -0
  51. package/dist/tools/registry.js.map +1 -0
  52. package/dist/tools/sites.d.ts +188 -0
  53. package/dist/tools/sites.d.ts.map +1 -0
  54. package/dist/tools/sites.js +258 -0
  55. package/dist/tools/sites.js.map +1 -0
  56. package/dist/tools/users.d.ts +317 -0
  57. package/dist/tools/users.d.ts.map +1 -0
  58. package/dist/tools/users.js +489 -0
  59. package/dist/tools/users.js.map +1 -0
  60. package/dist/types/halopsa.d.ts +212 -0
  61. package/dist/types/halopsa.d.ts.map +1 -0
  62. package/dist/types/halopsa.js +8 -0
  63. package/dist/types/halopsa.js.map +1 -0
  64. package/dist/utils/formatter.d.ts +18 -0
  65. package/dist/utils/formatter.d.ts.map +1 -0
  66. package/dist/utils/formatter.js +178 -0
  67. package/dist/utils/formatter.js.map +1 -0
  68. package/dist/utils/similarity.d.ts +25 -0
  69. package/dist/utils/similarity.d.ts.map +1 -0
  70. package/dist/utils/similarity.js +90 -0
  71. package/dist/utils/similarity.js.map +1 -0
  72. package/dist/utils/zod-to-schema.d.ts +29 -0
  73. package/dist/utils/zod-to-schema.d.ts.map +1 -0
  74. package/dist/utils/zod-to-schema.js +182 -0
  75. package/dist/utils/zod-to-schema.js.map +1 -0
  76. 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