@planescrm/sdk 0.2.0 → 0.4.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/dist/index.d.mts +685 -106
- package/dist/index.d.ts +685 -106
- package/dist/index.js +580 -190
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +567 -187
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -2
package/dist/index.mjs
CHANGED
|
@@ -1,62 +1,74 @@
|
|
|
1
1
|
// src/config.ts
|
|
2
|
+
var MemoryTokenStorage = class {
|
|
3
|
+
constructor(seed) {
|
|
4
|
+
this.tokens = seed ?? null;
|
|
5
|
+
}
|
|
6
|
+
async getTokens() {
|
|
7
|
+
return this.tokens;
|
|
8
|
+
}
|
|
9
|
+
async setTokens(tokens) {
|
|
10
|
+
this.tokens = tokens;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
2
13
|
var DEFAULT_BASE_URL = "https://api.planescrm.com/api/v1";
|
|
3
14
|
var DEFAULT_TIMEOUT = 3e4;
|
|
4
15
|
var DEFAULT_MAX_RETRIES = 3;
|
|
16
|
+
var DEFAULT_TOKEN_REFRESH_SKEW_MS = 6e4;
|
|
5
17
|
|
|
6
18
|
// src/errors.ts
|
|
7
|
-
var
|
|
19
|
+
var PlanesError = class extends Error {
|
|
8
20
|
constructor(message, status, code) {
|
|
9
21
|
super(message);
|
|
10
|
-
this.name = "
|
|
22
|
+
this.name = "PlanesError";
|
|
11
23
|
this.status = status;
|
|
12
24
|
this.code = code;
|
|
13
25
|
}
|
|
14
26
|
};
|
|
15
|
-
var AuthenticationError = class extends
|
|
27
|
+
var AuthenticationError = class extends PlanesError {
|
|
16
28
|
constructor(message = "Authentication failed") {
|
|
17
29
|
super(message, 401, "authentication_error");
|
|
18
30
|
this.name = "AuthenticationError";
|
|
19
31
|
}
|
|
20
32
|
};
|
|
21
|
-
var AuthorizationError = class extends
|
|
33
|
+
var AuthorizationError = class extends PlanesError {
|
|
22
34
|
constructor(message = "Insufficient permissions") {
|
|
23
35
|
super(message, 403, "authorization_error");
|
|
24
36
|
this.name = "AuthorizationError";
|
|
25
37
|
}
|
|
26
38
|
};
|
|
27
|
-
var NotFoundError = class extends
|
|
39
|
+
var NotFoundError = class extends PlanesError {
|
|
28
40
|
constructor(message = "Resource not found") {
|
|
29
41
|
super(message, 404, "not_found");
|
|
30
42
|
this.name = "NotFoundError";
|
|
31
43
|
}
|
|
32
44
|
};
|
|
33
|
-
var ValidationError = class extends
|
|
45
|
+
var ValidationError = class extends PlanesError {
|
|
34
46
|
constructor(message = "Validation failed", fieldErrors = []) {
|
|
35
47
|
super(message, 400, "validation_error");
|
|
36
48
|
this.name = "ValidationError";
|
|
37
49
|
this.fieldErrors = fieldErrors;
|
|
38
50
|
}
|
|
39
51
|
};
|
|
40
|
-
var RateLimitError = class extends
|
|
52
|
+
var RateLimitError = class extends PlanesError {
|
|
41
53
|
constructor(retryAfter = 1, message = "Rate limit exceeded") {
|
|
42
54
|
super(message, 429, "rate_limit_error");
|
|
43
55
|
this.name = "RateLimitError";
|
|
44
56
|
this.retryAfter = retryAfter;
|
|
45
57
|
}
|
|
46
58
|
};
|
|
47
|
-
var ServerError = class extends
|
|
59
|
+
var ServerError = class extends PlanesError {
|
|
48
60
|
constructor(message = "Internal server error") {
|
|
49
61
|
super(message, 500, "server_error");
|
|
50
62
|
this.name = "ServerError";
|
|
51
63
|
}
|
|
52
64
|
};
|
|
53
|
-
var WebhookVerificationError = class extends
|
|
65
|
+
var WebhookVerificationError = class extends PlanesError {
|
|
54
66
|
constructor(message = "Webhook signature verification failed") {
|
|
55
67
|
super(message, 400, "webhook_verification_error");
|
|
56
68
|
this.name = "WebhookVerificationError";
|
|
57
69
|
}
|
|
58
70
|
};
|
|
59
|
-
var OAuthError = class extends
|
|
71
|
+
var OAuthError = class extends PlanesError {
|
|
60
72
|
constructor(errorCode, message = "OAuth error") {
|
|
61
73
|
super(message, 400, "oauth_error");
|
|
62
74
|
this.name = "OAuthError";
|
|
@@ -64,6 +76,87 @@ var OAuthError = class extends BlueprintError {
|
|
|
64
76
|
}
|
|
65
77
|
};
|
|
66
78
|
|
|
79
|
+
// src/oauth.ts
|
|
80
|
+
import { randomBytes } from "crypto";
|
|
81
|
+
function buildAuthorizeUrl(options) {
|
|
82
|
+
const state = options.state ?? randomBytes(16).toString("hex");
|
|
83
|
+
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
84
|
+
const url = new URL(`${baseUrl}/oauth/authorize`);
|
|
85
|
+
url.searchParams.set("client_id", options.clientId);
|
|
86
|
+
url.searchParams.set("redirect_uri", options.redirectUri);
|
|
87
|
+
url.searchParams.set("scope", options.scopes.join(" "));
|
|
88
|
+
url.searchParams.set("response_type", "code");
|
|
89
|
+
url.searchParams.set("state", state);
|
|
90
|
+
return url.toString();
|
|
91
|
+
}
|
|
92
|
+
async function exchangeCode(options) {
|
|
93
|
+
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
94
|
+
const url = `${baseUrl}/oauth/token`;
|
|
95
|
+
const body = new URLSearchParams({
|
|
96
|
+
grant_type: "authorization_code",
|
|
97
|
+
client_id: options.clientId,
|
|
98
|
+
client_secret: options.clientSecret,
|
|
99
|
+
code: options.code,
|
|
100
|
+
redirect_uri: options.redirectUri
|
|
101
|
+
});
|
|
102
|
+
const response = await fetch(url, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
105
|
+
body: body.toString()
|
|
106
|
+
});
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
const errorBody = await response.json().catch(() => ({}));
|
|
109
|
+
const errorCode = errorBody["error"] ?? "unknown_error";
|
|
110
|
+
const errorDescription = errorBody["error_description"] ?? "Token exchange failed";
|
|
111
|
+
throw new OAuthError(errorCode, errorDescription);
|
|
112
|
+
}
|
|
113
|
+
return await response.json();
|
|
114
|
+
}
|
|
115
|
+
async function refreshAccessToken(options) {
|
|
116
|
+
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
117
|
+
const url = `${baseUrl}/oauth/token`;
|
|
118
|
+
const body = new URLSearchParams({
|
|
119
|
+
grant_type: "refresh_token",
|
|
120
|
+
client_id: options.clientId,
|
|
121
|
+
client_secret: options.clientSecret,
|
|
122
|
+
refresh_token: options.refreshToken
|
|
123
|
+
});
|
|
124
|
+
const response = await fetch(url, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
127
|
+
body: body.toString()
|
|
128
|
+
});
|
|
129
|
+
if (!response.ok) {
|
|
130
|
+
const errorBody = await response.json().catch(() => ({}));
|
|
131
|
+
const errorCode = errorBody["error"] ?? "unknown_error";
|
|
132
|
+
const errorDescription = errorBody["error_description"] ?? "Token refresh failed";
|
|
133
|
+
throw new OAuthError(errorCode, errorDescription);
|
|
134
|
+
}
|
|
135
|
+
return await response.json();
|
|
136
|
+
}
|
|
137
|
+
async function clientCredentialsGrant(options) {
|
|
138
|
+
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
139
|
+
const url = `${baseUrl}/oauth/token`;
|
|
140
|
+
const body = new URLSearchParams({
|
|
141
|
+
grant_type: "client_credentials",
|
|
142
|
+
client_id: options.clientId,
|
|
143
|
+
client_secret: options.clientSecret,
|
|
144
|
+
scope: options.scopes.join(" ")
|
|
145
|
+
});
|
|
146
|
+
const response = await fetch(url, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
149
|
+
body: body.toString()
|
|
150
|
+
});
|
|
151
|
+
if (!response.ok) {
|
|
152
|
+
const errorBody = await response.json().catch(() => ({}));
|
|
153
|
+
const errorCode = errorBody["error"] ?? "unknown_error";
|
|
154
|
+
const errorDescription = errorBody["error_description"] ?? "Client credentials grant failed";
|
|
155
|
+
throw new OAuthError(errorCode, errorDescription);
|
|
156
|
+
}
|
|
157
|
+
return await response.json();
|
|
158
|
+
}
|
|
159
|
+
|
|
67
160
|
// src/http.ts
|
|
68
161
|
var HttpClient = class {
|
|
69
162
|
constructor(config) {
|
|
@@ -73,24 +166,35 @@ var HttpClient = class {
|
|
|
73
166
|
this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
|
74
167
|
this.retryOnRateLimit = config.retryOnRateLimit !== false;
|
|
75
168
|
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
169
|
+
if (config.clientId && config.clientSecret) {
|
|
170
|
+
this.oauth = {
|
|
171
|
+
clientId: config.clientId,
|
|
172
|
+
clientSecret: config.clientSecret,
|
|
173
|
+
tokenStorage: config.tokenStorage ?? new MemoryTokenStorage(),
|
|
174
|
+
skewMs: config.tokenRefreshSkewMs ?? DEFAULT_TOKEN_REFRESH_SKEW_MS,
|
|
175
|
+
scopes: config.scopes
|
|
176
|
+
};
|
|
177
|
+
}
|
|
76
178
|
}
|
|
77
179
|
async request(options) {
|
|
78
180
|
const url = this.buildUrl(options.path, options.params);
|
|
79
|
-
const
|
|
80
|
-
"Content-Type": "application/json",
|
|
81
|
-
...this.getAuthHeaders(),
|
|
82
|
-
...options.headers
|
|
83
|
-
};
|
|
84
|
-
const fetchOptions = {
|
|
85
|
-
method: options.method,
|
|
86
|
-
headers,
|
|
87
|
-
signal: AbortSignal.timeout(this.timeout)
|
|
88
|
-
};
|
|
89
|
-
if (options.body !== void 0) {
|
|
90
|
-
fetchOptions.body = JSON.stringify(options.body);
|
|
91
|
-
}
|
|
181
|
+
const bodyStr = options.body !== void 0 ? JSON.stringify(options.body) : void 0;
|
|
92
182
|
let lastError;
|
|
183
|
+
let didRefreshOn401 = false;
|
|
93
184
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
185
|
+
const headers = {
|
|
186
|
+
"Content-Type": "application/json",
|
|
187
|
+
...await this.resolveAuthHeaders(),
|
|
188
|
+
...options.headers
|
|
189
|
+
};
|
|
190
|
+
const fetchOptions = {
|
|
191
|
+
method: options.method,
|
|
192
|
+
headers,
|
|
193
|
+
signal: AbortSignal.timeout(this.timeout)
|
|
194
|
+
};
|
|
195
|
+
if (bodyStr !== void 0) {
|
|
196
|
+
fetchOptions.body = bodyStr;
|
|
197
|
+
}
|
|
94
198
|
const response = await fetch(url, fetchOptions);
|
|
95
199
|
if (response.ok) {
|
|
96
200
|
if (response.status === 204) {
|
|
@@ -103,6 +207,12 @@ var HttpClient = class {
|
|
|
103
207
|
await this.sleep(retryAfter * 1e3);
|
|
104
208
|
continue;
|
|
105
209
|
}
|
|
210
|
+
if (response.status === 401 && this.oauth && !didRefreshOn401) {
|
|
211
|
+
didRefreshOn401 = true;
|
|
212
|
+
await this.refreshTokens();
|
|
213
|
+
attempt--;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
106
216
|
lastError = await this.mapError(response);
|
|
107
217
|
if (response.status !== 429 || !this.retryOnRateLimit) {
|
|
108
218
|
throw lastError;
|
|
@@ -119,6 +229,76 @@ var HttpClient = class {
|
|
|
119
229
|
}
|
|
120
230
|
return {};
|
|
121
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Resolve the Authorization header for the current request. For the static
|
|
234
|
+
* accessToken/apiKey modes this is {@link getAuthHeaders}. For the
|
|
235
|
+
* OAuth-managed path it reads the stored token, proactively refreshing when
|
|
236
|
+
* the token is within the skew window of expiry.
|
|
237
|
+
*/
|
|
238
|
+
async resolveAuthHeaders() {
|
|
239
|
+
if (!this.oauth) {
|
|
240
|
+
return this.getAuthHeaders();
|
|
241
|
+
}
|
|
242
|
+
const tokens = await this.oauth.tokenStorage.getTokens();
|
|
243
|
+
if (!tokens) {
|
|
244
|
+
return {};
|
|
245
|
+
}
|
|
246
|
+
if (tokens.expiresAt - Date.now() <= this.oauth.skewMs) {
|
|
247
|
+
const refreshed = await this.refreshTokens();
|
|
248
|
+
return { Authorization: `Bearer ${refreshed.accessToken}` };
|
|
249
|
+
}
|
|
250
|
+
return { Authorization: `Bearer ${tokens.accessToken}` };
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Refresh the OAuth access token via `refreshAccessToken` and persist the new
|
|
254
|
+
* tokens through the configured {@link TokenStorage}. Concurrent callers share
|
|
255
|
+
* a single in-flight refresh.
|
|
256
|
+
*/
|
|
257
|
+
async refreshTokens() {
|
|
258
|
+
if (!this.oauth) {
|
|
259
|
+
throw new AuthenticationError("Token refresh requires the OAuth clientId/clientSecret path");
|
|
260
|
+
}
|
|
261
|
+
if (this.refreshInFlight) {
|
|
262
|
+
return this.refreshInFlight;
|
|
263
|
+
}
|
|
264
|
+
const oauth = this.oauth;
|
|
265
|
+
this.refreshInFlight = (async () => {
|
|
266
|
+
const current = await oauth.tokenStorage.getTokens();
|
|
267
|
+
if (!current?.refreshToken) {
|
|
268
|
+
const response2 = await clientCredentialsGrant({
|
|
269
|
+
baseUrl: this.baseUrl,
|
|
270
|
+
clientId: oauth.clientId,
|
|
271
|
+
clientSecret: oauth.clientSecret,
|
|
272
|
+
scopes: oauth.scopes ?? []
|
|
273
|
+
});
|
|
274
|
+
const next2 = {
|
|
275
|
+
accessToken: response2.access_token,
|
|
276
|
+
refreshToken: response2.refresh_token ?? null,
|
|
277
|
+
expiresAt: Date.now() + response2.expires_in * 1e3
|
|
278
|
+
};
|
|
279
|
+
await oauth.tokenStorage.setTokens(next2);
|
|
280
|
+
return next2;
|
|
281
|
+
}
|
|
282
|
+
const response = await refreshAccessToken({
|
|
283
|
+
baseUrl: this.baseUrl,
|
|
284
|
+
clientId: oauth.clientId,
|
|
285
|
+
clientSecret: oauth.clientSecret,
|
|
286
|
+
refreshToken: current.refreshToken
|
|
287
|
+
});
|
|
288
|
+
const next = {
|
|
289
|
+
accessToken: response.access_token,
|
|
290
|
+
refreshToken: response.refresh_token || current.refreshToken,
|
|
291
|
+
expiresAt: Date.now() + response.expires_in * 1e3
|
|
292
|
+
};
|
|
293
|
+
await oauth.tokenStorage.setTokens(next);
|
|
294
|
+
return next;
|
|
295
|
+
})();
|
|
296
|
+
try {
|
|
297
|
+
return await this.refreshInFlight;
|
|
298
|
+
} finally {
|
|
299
|
+
this.refreshInFlight = void 0;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
122
302
|
buildUrl(path, params) {
|
|
123
303
|
const url = new URL(`${this.baseUrl}${path}`);
|
|
124
304
|
if (params) {
|
|
@@ -166,7 +346,7 @@ var HttpClient = class {
|
|
|
166
346
|
if (response.status >= 500) {
|
|
167
347
|
return new ServerError(message);
|
|
168
348
|
}
|
|
169
|
-
return new
|
|
349
|
+
return new PlanesError(message, response.status, "unknown_error");
|
|
170
350
|
}
|
|
171
351
|
}
|
|
172
352
|
sleep(ms) {
|
|
@@ -293,14 +473,207 @@ var InventoryResource = class extends BaseResource {
|
|
|
293
473
|
}
|
|
294
474
|
};
|
|
295
475
|
|
|
476
|
+
// src/resources/contacts.ts
|
|
477
|
+
var ContactChannelResource = class {
|
|
478
|
+
constructor(http2, contactId) {
|
|
479
|
+
this.http = http2;
|
|
480
|
+
this.basePath = `/contacts/${contactId}/channels`;
|
|
481
|
+
}
|
|
482
|
+
/** Returns `{ channel, duplicateOf }` — `duplicateOf` is the dedup signal. */
|
|
483
|
+
async create(data) {
|
|
484
|
+
return this.http.request({
|
|
485
|
+
method: "POST",
|
|
486
|
+
path: this.basePath,
|
|
487
|
+
body: data
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
async update(channelId, data) {
|
|
491
|
+
return this.http.request({
|
|
492
|
+
method: "PATCH",
|
|
493
|
+
path: `${this.basePath}/${channelId}`,
|
|
494
|
+
body: data
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
async delete(channelId) {
|
|
498
|
+
await this.http.request({
|
|
499
|
+
method: "DELETE",
|
|
500
|
+
path: `${this.basePath}/${channelId}`
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
var ContactResource = class extends BaseResource {
|
|
505
|
+
constructor(http2) {
|
|
506
|
+
super(http2, "/contacts");
|
|
507
|
+
}
|
|
508
|
+
async merge(winnerId, loserId) {
|
|
509
|
+
return this.http.request({
|
|
510
|
+
method: "POST",
|
|
511
|
+
path: `/contacts/${winnerId}/merge/${loserId}`
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
channels(contactId) {
|
|
515
|
+
return new ContactChannelResource(this.http, contactId);
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
// src/resources/activities.ts
|
|
520
|
+
var ActivityResource = class {
|
|
521
|
+
constructor(http2) {
|
|
522
|
+
this.http = http2;
|
|
523
|
+
}
|
|
524
|
+
async create(data) {
|
|
525
|
+
return this.http.request({
|
|
526
|
+
method: "POST",
|
|
527
|
+
path: "/activities",
|
|
528
|
+
body: data
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
async list(params) {
|
|
532
|
+
return this.http.request({
|
|
533
|
+
method: "GET",
|
|
534
|
+
path: "/activities",
|
|
535
|
+
params
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
async listForEntity(entityType, entityId, params) {
|
|
539
|
+
return this.http.request({
|
|
540
|
+
method: "GET",
|
|
541
|
+
path: `/${entityType}/${entityId}/activities`,
|
|
542
|
+
params
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// src/resources/tags.ts
|
|
548
|
+
var TagResource = class extends BaseResource {
|
|
549
|
+
constructor(http2) {
|
|
550
|
+
super(http2, "/tags");
|
|
551
|
+
}
|
|
552
|
+
/** `TagsService.setEntityTags()` returns the final tag set wrapped in `{ tags }`. */
|
|
553
|
+
async setEntityTags(entityType, entityId, data) {
|
|
554
|
+
return this.http.request({
|
|
555
|
+
method: "PUT",
|
|
556
|
+
path: `/entities/${entityType}/${entityId}/tags`,
|
|
557
|
+
body: data
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
async getEntityTags(entityType, entityId) {
|
|
561
|
+
return this.http.request({
|
|
562
|
+
method: "GET",
|
|
563
|
+
path: `/entities/${entityType}/${entityId}/tags`
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
async getEntities(tagId, params) {
|
|
567
|
+
return this.http.request({
|
|
568
|
+
method: "GET",
|
|
569
|
+
path: `/tags/${tagId}/entities`,
|
|
570
|
+
params
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
// src/webhooks.ts
|
|
576
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
577
|
+
function verifyWebhookSignature(payload, signature, secret) {
|
|
578
|
+
if (!signature || !secret || !payload) {
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
const prefix = "sha256=";
|
|
582
|
+
if (!signature.startsWith(prefix)) {
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
const receivedHex = signature.slice(prefix.length);
|
|
586
|
+
if (!receivedHex) {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
const expectedHmac = createHmac("sha256", secret).update(payload).digest("hex");
|
|
590
|
+
const receivedBuf = Buffer.from(receivedHex, "hex");
|
|
591
|
+
const expectedBuf = Buffer.from(expectedHmac, "hex");
|
|
592
|
+
if (receivedBuf.length !== expectedBuf.length) {
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
return timingSafeEqual(receivedBuf, expectedBuf);
|
|
596
|
+
}
|
|
597
|
+
function parseWebhookEvent(body, signature, secret) {
|
|
598
|
+
if (!verifyWebhookSignature(body, signature, secret)) {
|
|
599
|
+
throw new WebhookVerificationError();
|
|
600
|
+
}
|
|
601
|
+
return JSON.parse(body);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// src/webhook-router.ts
|
|
605
|
+
var WebhookRouter = class {
|
|
606
|
+
constructor() {
|
|
607
|
+
/** pattern (composite key / entity wildcard / '*') → registered handlers. */
|
|
608
|
+
this.handlers = /* @__PURE__ */ new Map();
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Register a handler for an event pattern.
|
|
612
|
+
*
|
|
613
|
+
* @param pattern - `"{entity_type}.{event_type}"` (e.g. `opportunity.created`),
|
|
614
|
+
* an entity wildcard `"{entity_type}.*"` (e.g. `opportunity.*`), or the
|
|
615
|
+
* global wildcard `"*"`. Matching is exact-string against the derived key;
|
|
616
|
+
* see the class docs for the dotted-`event_type` shape.
|
|
617
|
+
* @param handler - Invoked with the full {@link WebhookDelivery} on a match.
|
|
618
|
+
* @returns `this`, for chaining.
|
|
619
|
+
*/
|
|
620
|
+
on(pattern, handler) {
|
|
621
|
+
const existing = this.handlers.get(pattern);
|
|
622
|
+
if (existing) {
|
|
623
|
+
existing.push(handler);
|
|
624
|
+
} else {
|
|
625
|
+
this.handlers.set(pattern, [handler]);
|
|
626
|
+
}
|
|
627
|
+
return this;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Verify, parse, and dispatch a webhook delivery.
|
|
631
|
+
*
|
|
632
|
+
* The signature is verified first: on failure this throws
|
|
633
|
+
* {@link WebhookVerificationError} and dispatches nothing. On success it
|
|
634
|
+
* dispatches to every matching handler — specific key, then entity wildcard,
|
|
635
|
+
* then global — awaiting async handlers before resolving.
|
|
636
|
+
*
|
|
637
|
+
* @param input - The raw `{ body, signature, secret }`.
|
|
638
|
+
* @returns The parsed {@link WebhookDelivery}.
|
|
639
|
+
* @throws WebhookVerificationError if the signature is invalid.
|
|
640
|
+
*/
|
|
641
|
+
async process(input) {
|
|
642
|
+
const delivery = parseWebhookEvent(input.body, input.signature, input.secret);
|
|
643
|
+
const { entity_type, event_type } = delivery.event;
|
|
644
|
+
const patterns = [`${entity_type}.${event_type}`, `${entity_type}.*`, "*"];
|
|
645
|
+
for (const pattern of patterns) {
|
|
646
|
+
const matched = this.handlers.get(pattern);
|
|
647
|
+
if (!matched) continue;
|
|
648
|
+
for (const handler of matched) {
|
|
649
|
+
await handler(delivery);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return delivery;
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
|
|
296
656
|
// src/client.ts
|
|
297
|
-
var
|
|
657
|
+
var PlanesClient = class {
|
|
298
658
|
constructor(config) {
|
|
299
|
-
|
|
300
|
-
|
|
659
|
+
const hasOAuthCreds = Boolean(config.clientId) || Boolean(config.clientSecret);
|
|
660
|
+
const modes = [
|
|
661
|
+
config.accessToken ? "accessToken" : null,
|
|
662
|
+
config.apiKey ? "apiKey" : null,
|
|
663
|
+
hasOAuthCreds ? "clientId+clientSecret" : null
|
|
664
|
+
].filter((m) => m !== null);
|
|
665
|
+
if (modes.length === 0) {
|
|
666
|
+
throw new Error(
|
|
667
|
+
"PlanesClient requires one auth mode: accessToken, apiKey, or clientId + clientSecret"
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
if (modes.length > 1) {
|
|
671
|
+
throw new Error(
|
|
672
|
+
`PlanesClient requires exactly one auth mode, not multiple (got: ${modes.join(", ")})`
|
|
673
|
+
);
|
|
301
674
|
}
|
|
302
|
-
if (config.
|
|
303
|
-
throw new Error("
|
|
675
|
+
if (hasOAuthCreds && (!config.clientId || !config.clientSecret)) {
|
|
676
|
+
throw new Error("PlanesClient OAuth mode requires both clientId and clientSecret");
|
|
304
677
|
}
|
|
305
678
|
this.http = new HttpClient(config);
|
|
306
679
|
}
|
|
@@ -358,17 +731,134 @@ var BlueprintClient = class {
|
|
|
358
731
|
}
|
|
359
732
|
return this._inventory;
|
|
360
733
|
}
|
|
734
|
+
get contacts() {
|
|
735
|
+
if (!this._contacts) {
|
|
736
|
+
this._contacts = new ContactResource(this.http);
|
|
737
|
+
}
|
|
738
|
+
return this._contacts;
|
|
739
|
+
}
|
|
740
|
+
get activities() {
|
|
741
|
+
if (!this._activities) {
|
|
742
|
+
this._activities = new ActivityResource(this.http);
|
|
743
|
+
}
|
|
744
|
+
return this._activities;
|
|
745
|
+
}
|
|
746
|
+
get tags() {
|
|
747
|
+
if (!this._tags) {
|
|
748
|
+
this._tags = new TagResource(this.http);
|
|
749
|
+
}
|
|
750
|
+
return this._tags;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Ergonomic webhook event router — `client.webhooks.on(pattern, handler)`
|
|
754
|
+
* plus `client.webhooks.process({ body, signature, secret })`. Auth-independent
|
|
755
|
+
* (the signing secret is supplied per `.process()` call), so it is created
|
|
756
|
+
* lazily without the HTTP client. See {@link WebhookRouter}.
|
|
757
|
+
*/
|
|
758
|
+
get webhooks() {
|
|
759
|
+
if (!this._webhooks) {
|
|
760
|
+
this._webhooks = new WebhookRouter();
|
|
761
|
+
}
|
|
762
|
+
return this._webhooks;
|
|
763
|
+
}
|
|
361
764
|
};
|
|
362
765
|
|
|
363
766
|
// src/agent.ts
|
|
364
767
|
import * as http from "http";
|
|
365
|
-
|
|
768
|
+
function createActionServer(handlerMap, options) {
|
|
769
|
+
const port = options?.port ?? 3e3;
|
|
770
|
+
const basePath = options?.path ?? "/actions";
|
|
771
|
+
const onError = options?.onError;
|
|
772
|
+
const server = http.createServer(async (req, res) => {
|
|
773
|
+
if (req.method !== "POST" || req.url !== basePath) {
|
|
774
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
775
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
try {
|
|
779
|
+
const body = await readBody(req);
|
|
780
|
+
let envelope;
|
|
781
|
+
try {
|
|
782
|
+
envelope = JSON.parse(body);
|
|
783
|
+
} catch {
|
|
784
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
785
|
+
res.end(JSON.stringify({ error: "Malformed request body" }));
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const wire = envelope?.action_context;
|
|
789
|
+
const actionType = wire?.action_type;
|
|
790
|
+
if (!wire || !actionType) {
|
|
791
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
792
|
+
res.end(
|
|
793
|
+
JSON.stringify({ error: "Missing action_context.action_type in request body" })
|
|
794
|
+
);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const handler = handlerMap.get(actionType);
|
|
798
|
+
if (!handler) {
|
|
799
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
800
|
+
res.end(
|
|
801
|
+
JSON.stringify({
|
|
802
|
+
error: `No handler registered for action type: ${actionType}`
|
|
803
|
+
})
|
|
804
|
+
);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
const requestIdHeader = req.headers["x-planes-request-id"];
|
|
808
|
+
const requestId = Array.isArray(requestIdHeader) ? requestIdHeader[0] : requestIdHeader;
|
|
809
|
+
const context = {
|
|
810
|
+
action_id: wire.action_id,
|
|
811
|
+
action_type: actionType,
|
|
812
|
+
entity_type: wire.entity_type,
|
|
813
|
+
entity_id: wire.entity_id,
|
|
814
|
+
tenant_id: wire.tenant_id,
|
|
815
|
+
data: wire.data ?? {},
|
|
816
|
+
stage: wire.stage ?? null,
|
|
817
|
+
metadata: wire.metadata,
|
|
818
|
+
callback_token: envelope.callback_token,
|
|
819
|
+
callback_base_url: envelope.callback_base_url,
|
|
820
|
+
// Only set request_id when the header was present (exactOptionalPropertyTypes).
|
|
821
|
+
...requestId !== void 0 ? { request_id: requestId } : {}
|
|
822
|
+
};
|
|
823
|
+
let result;
|
|
824
|
+
try {
|
|
825
|
+
result = await handler.execute(context);
|
|
826
|
+
} catch (err) {
|
|
827
|
+
const errorMessage = err instanceof Error ? err.message : "Unknown handler error";
|
|
828
|
+
if (onError && err instanceof Error) {
|
|
829
|
+
onError(err);
|
|
830
|
+
}
|
|
831
|
+
result = {
|
|
832
|
+
status: "failed",
|
|
833
|
+
error: errorMessage,
|
|
834
|
+
retryable: false
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
838
|
+
res.end(JSON.stringify(result));
|
|
839
|
+
} catch (err) {
|
|
840
|
+
if (onError && err instanceof Error) {
|
|
841
|
+
onError(err);
|
|
842
|
+
}
|
|
843
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
844
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
server.listen(port);
|
|
848
|
+
const shutdown = () => {
|
|
849
|
+
server.close();
|
|
850
|
+
};
|
|
851
|
+
process.on("SIGTERM", shutdown);
|
|
852
|
+
process.on("SIGINT", shutdown);
|
|
853
|
+
return server;
|
|
854
|
+
}
|
|
855
|
+
var Planes = {
|
|
366
856
|
/**
|
|
367
857
|
* Register action handlers and start an HTTP server to receive action requests.
|
|
368
858
|
*
|
|
369
859
|
* @example
|
|
370
860
|
* ```ts
|
|
371
|
-
*
|
|
861
|
+
* Planes.register([
|
|
372
862
|
* {
|
|
373
863
|
* type: 'send_email',
|
|
374
864
|
* async execute(ctx) {
|
|
@@ -380,77 +870,45 @@ var Blueprint = {
|
|
|
380
870
|
* ```
|
|
381
871
|
*/
|
|
382
872
|
register(handlers, options) {
|
|
383
|
-
const port = options?.port ?? 3e3;
|
|
384
|
-
const basePath = options?.path ?? "/actions";
|
|
385
|
-
const onError = options?.onError;
|
|
386
873
|
const handlerMap = /* @__PURE__ */ new Map();
|
|
387
874
|
for (const handler of handlers) {
|
|
388
875
|
handlerMap.set(handler.type, handler);
|
|
389
876
|
}
|
|
390
|
-
|
|
391
|
-
if (req.method !== "POST" || req.url !== basePath) {
|
|
392
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
393
|
-
res.end(JSON.stringify({ error: "Not found" }));
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
try {
|
|
397
|
-
const body = await readBody(req);
|
|
398
|
-
let context;
|
|
399
|
-
try {
|
|
400
|
-
context = JSON.parse(body);
|
|
401
|
-
} catch {
|
|
402
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
403
|
-
res.end(JSON.stringify({ error: "Malformed request body" }));
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
if (!context.action?.type) {
|
|
407
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
408
|
-
res.end(JSON.stringify({ error: "Missing action.type in request body" }));
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
const handler = handlerMap.get(context.action.type);
|
|
412
|
-
if (!handler) {
|
|
413
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
414
|
-
res.end(
|
|
415
|
-
JSON.stringify({
|
|
416
|
-
error: `No handler registered for action type: ${context.action.type}`
|
|
417
|
-
})
|
|
418
|
-
);
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
let result;
|
|
422
|
-
try {
|
|
423
|
-
result = await handler.execute(context);
|
|
424
|
-
} catch (err) {
|
|
425
|
-
const errorMessage = err instanceof Error ? err.message : "Unknown handler error";
|
|
426
|
-
if (onError && err instanceof Error) {
|
|
427
|
-
onError(err);
|
|
428
|
-
}
|
|
429
|
-
result = {
|
|
430
|
-
status: "failed",
|
|
431
|
-
error: errorMessage,
|
|
432
|
-
retryable: false
|
|
433
|
-
};
|
|
434
|
-
}
|
|
435
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
436
|
-
res.end(JSON.stringify(result));
|
|
437
|
-
} catch (err) {
|
|
438
|
-
if (onError && err instanceof Error) {
|
|
439
|
-
onError(err);
|
|
440
|
-
}
|
|
441
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
442
|
-
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
443
|
-
}
|
|
444
|
-
});
|
|
445
|
-
server.listen(port);
|
|
446
|
-
const shutdown = () => {
|
|
447
|
-
server.close();
|
|
448
|
-
};
|
|
449
|
-
process.on("SIGTERM", shutdown);
|
|
450
|
-
process.on("SIGINT", shutdown);
|
|
451
|
-
return server;
|
|
877
|
+
return createActionServer(handlerMap, options);
|
|
452
878
|
}
|
|
453
879
|
};
|
|
880
|
+
var AgentApp = class {
|
|
881
|
+
constructor() {
|
|
882
|
+
this.handlers = /* @__PURE__ */ new Map();
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Register a handler for an action type. Accepts a bare `execute` function
|
|
886
|
+
* (the caller does not build the `{ type, execute }` object).
|
|
887
|
+
*
|
|
888
|
+
* @param type - The action type this handler responds to.
|
|
889
|
+
* @param execute - The handler function invoked with the {@link ActionContext}.
|
|
890
|
+
* @returns `this`, for chaining.
|
|
891
|
+
*/
|
|
892
|
+
registerAction(type, execute) {
|
|
893
|
+
this.handlers.set(type, { type, execute });
|
|
894
|
+
return this;
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Start an HTTP server that dispatches incoming action requests to the
|
|
898
|
+
* registered handlers. Delegates to the same server factory as
|
|
899
|
+
* {@link Planes.register}.
|
|
900
|
+
*
|
|
901
|
+
* The handler set is snapshotted at call time, so registering further actions
|
|
902
|
+
* after `listen()` does not mutate the running server (parity with
|
|
903
|
+
* `Planes.register`, which snapshots its array).
|
|
904
|
+
*/
|
|
905
|
+
listen(options) {
|
|
906
|
+
return createActionServer(new Map(this.handlers), options);
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
function createAgentApp() {
|
|
910
|
+
return new AgentApp();
|
|
911
|
+
}
|
|
454
912
|
function readBody(req) {
|
|
455
913
|
return new Promise((resolve, reject) => {
|
|
456
914
|
const chunks = [];
|
|
@@ -459,120 +917,42 @@ function readBody(req) {
|
|
|
459
917
|
req.on("error", reject);
|
|
460
918
|
});
|
|
461
919
|
}
|
|
462
|
-
|
|
463
|
-
// src/webhooks.ts
|
|
464
|
-
import { createHmac, timingSafeEqual } from "crypto";
|
|
465
|
-
function verifyWebhookSignature(payload, signature, secret) {
|
|
466
|
-
if (!signature || !secret || !payload) {
|
|
467
|
-
return false;
|
|
468
|
-
}
|
|
469
|
-
const prefix = "sha256=";
|
|
470
|
-
if (!signature.startsWith(prefix)) {
|
|
471
|
-
return false;
|
|
472
|
-
}
|
|
473
|
-
const receivedHex = signature.slice(prefix.length);
|
|
474
|
-
if (!receivedHex) {
|
|
475
|
-
return false;
|
|
476
|
-
}
|
|
477
|
-
const expectedHmac = createHmac("sha256", secret).update(payload).digest("hex");
|
|
478
|
-
const receivedBuf = Buffer.from(receivedHex, "hex");
|
|
479
|
-
const expectedBuf = Buffer.from(expectedHmac, "hex");
|
|
480
|
-
if (receivedBuf.length !== expectedBuf.length) {
|
|
481
|
-
return false;
|
|
482
|
-
}
|
|
483
|
-
return timingSafeEqual(receivedBuf, expectedBuf);
|
|
484
|
-
}
|
|
485
|
-
function parseWebhookEvent(body, signature, secret) {
|
|
486
|
-
if (!verifyWebhookSignature(body, signature, secret)) {
|
|
487
|
-
throw new WebhookVerificationError();
|
|
488
|
-
}
|
|
489
|
-
return JSON.parse(body);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// src/oauth.ts
|
|
493
|
-
import { randomBytes } from "crypto";
|
|
494
|
-
function buildAuthorizeUrl(options) {
|
|
495
|
-
const state = options.state ?? randomBytes(16).toString("hex");
|
|
496
|
-
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
497
|
-
const url = new URL(`${baseUrl}/oauth/authorize`);
|
|
498
|
-
url.searchParams.set("client_id", options.clientId);
|
|
499
|
-
url.searchParams.set("redirect_uri", options.redirectUri);
|
|
500
|
-
url.searchParams.set("scope", options.scopes.join(" "));
|
|
501
|
-
url.searchParams.set("response_type", "code");
|
|
502
|
-
url.searchParams.set("state", state);
|
|
503
|
-
return url.toString();
|
|
504
|
-
}
|
|
505
|
-
async function exchangeCode(options) {
|
|
506
|
-
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
507
|
-
const url = `${baseUrl}/oauth/token`;
|
|
508
|
-
const body = new URLSearchParams({
|
|
509
|
-
grant_type: "authorization_code",
|
|
510
|
-
client_id: options.clientId,
|
|
511
|
-
client_secret: options.clientSecret,
|
|
512
|
-
code: options.code,
|
|
513
|
-
redirect_uri: options.redirectUri
|
|
514
|
-
});
|
|
515
|
-
const response = await fetch(url, {
|
|
516
|
-
method: "POST",
|
|
517
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
518
|
-
body: body.toString()
|
|
519
|
-
});
|
|
520
|
-
if (!response.ok) {
|
|
521
|
-
const errorBody = await response.json().catch(() => ({}));
|
|
522
|
-
const errorCode = errorBody["error"] ?? "unknown_error";
|
|
523
|
-
const errorDescription = errorBody["error_description"] ?? "Token exchange failed";
|
|
524
|
-
throw new OAuthError(errorCode, errorDescription);
|
|
525
|
-
}
|
|
526
|
-
return await response.json();
|
|
527
|
-
}
|
|
528
|
-
async function refreshAccessToken(options) {
|
|
529
|
-
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
530
|
-
const url = `${baseUrl}/oauth/token`;
|
|
531
|
-
const body = new URLSearchParams({
|
|
532
|
-
grant_type: "refresh_token",
|
|
533
|
-
client_id: options.clientId,
|
|
534
|
-
client_secret: options.clientSecret,
|
|
535
|
-
refresh_token: options.refreshToken
|
|
536
|
-
});
|
|
537
|
-
const response = await fetch(url, {
|
|
538
|
-
method: "POST",
|
|
539
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
540
|
-
body: body.toString()
|
|
541
|
-
});
|
|
542
|
-
if (!response.ok) {
|
|
543
|
-
const errorBody = await response.json().catch(() => ({}));
|
|
544
|
-
const errorCode = errorBody["error"] ?? "unknown_error";
|
|
545
|
-
const errorDescription = errorBody["error_description"] ?? "Token refresh failed";
|
|
546
|
-
throw new OAuthError(errorCode, errorDescription);
|
|
547
|
-
}
|
|
548
|
-
return await response.json();
|
|
549
|
-
}
|
|
550
920
|
export {
|
|
921
|
+
ActivityResource,
|
|
922
|
+
AgentApp,
|
|
551
923
|
AuthenticationError,
|
|
552
924
|
AuthorizationError,
|
|
553
925
|
BaseResource,
|
|
554
|
-
Blueprint,
|
|
555
|
-
BlueprintClient,
|
|
556
|
-
BlueprintError,
|
|
557
926
|
CalendarEventResource,
|
|
927
|
+
ContactChannelResource,
|
|
928
|
+
ContactResource,
|
|
558
929
|
ContractResource,
|
|
559
930
|
CustomerResource,
|
|
560
931
|
DEFAULT_BASE_URL,
|
|
561
932
|
DEFAULT_MAX_RETRIES,
|
|
562
933
|
DEFAULT_TIMEOUT,
|
|
934
|
+
DEFAULT_TOKEN_REFRESH_SKEW_MS,
|
|
563
935
|
EstimateResource,
|
|
564
936
|
InventoryResource,
|
|
565
937
|
InvoiceResource,
|
|
566
938
|
JobResource,
|
|
939
|
+
MemoryTokenStorage,
|
|
567
940
|
NotFoundError,
|
|
568
941
|
OAuthError,
|
|
569
942
|
OpportunityResource,
|
|
943
|
+
Planes,
|
|
944
|
+
PlanesClient,
|
|
945
|
+
PlanesError,
|
|
570
946
|
PropertyResource,
|
|
571
947
|
RateLimitError,
|
|
572
948
|
ServerError,
|
|
949
|
+
TagResource,
|
|
573
950
|
ValidationError,
|
|
951
|
+
WebhookRouter,
|
|
574
952
|
WebhookVerificationError,
|
|
575
953
|
buildAuthorizeUrl,
|
|
954
|
+
clientCredentialsGrant,
|
|
955
|
+
createAgentApp,
|
|
576
956
|
exchangeCode,
|
|
577
957
|
parseWebhookEvent,
|
|
578
958
|
refreshAccessToken,
|