@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.js CHANGED
@@ -30,31 +30,41 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ ActivityResource: () => ActivityResource,
34
+ AgentApp: () => AgentApp,
33
35
  AuthenticationError: () => AuthenticationError,
34
36
  AuthorizationError: () => AuthorizationError,
35
37
  BaseResource: () => BaseResource,
36
- Blueprint: () => Blueprint,
37
- BlueprintClient: () => BlueprintClient,
38
- BlueprintError: () => BlueprintError,
39
38
  CalendarEventResource: () => CalendarEventResource,
39
+ ContactChannelResource: () => ContactChannelResource,
40
+ ContactResource: () => ContactResource,
40
41
  ContractResource: () => ContractResource,
41
42
  CustomerResource: () => CustomerResource,
42
43
  DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
43
44
  DEFAULT_MAX_RETRIES: () => DEFAULT_MAX_RETRIES,
44
45
  DEFAULT_TIMEOUT: () => DEFAULT_TIMEOUT,
46
+ DEFAULT_TOKEN_REFRESH_SKEW_MS: () => DEFAULT_TOKEN_REFRESH_SKEW_MS,
45
47
  EstimateResource: () => EstimateResource,
46
48
  InventoryResource: () => InventoryResource,
47
49
  InvoiceResource: () => InvoiceResource,
48
50
  JobResource: () => JobResource,
51
+ MemoryTokenStorage: () => MemoryTokenStorage,
49
52
  NotFoundError: () => NotFoundError,
50
53
  OAuthError: () => OAuthError,
51
54
  OpportunityResource: () => OpportunityResource,
55
+ Planes: () => Planes,
56
+ PlanesClient: () => PlanesClient,
57
+ PlanesError: () => PlanesError,
52
58
  PropertyResource: () => PropertyResource,
53
59
  RateLimitError: () => RateLimitError,
54
60
  ServerError: () => ServerError,
61
+ TagResource: () => TagResource,
55
62
  ValidationError: () => ValidationError,
63
+ WebhookRouter: () => WebhookRouter,
56
64
  WebhookVerificationError: () => WebhookVerificationError,
57
65
  buildAuthorizeUrl: () => buildAuthorizeUrl,
66
+ clientCredentialsGrant: () => clientCredentialsGrant,
67
+ createAgentApp: () => createAgentApp,
58
68
  exchangeCode: () => exchangeCode,
59
69
  parseWebhookEvent: () => parseWebhookEvent,
60
70
  refreshAccessToken: () => refreshAccessToken,
@@ -63,64 +73,76 @@ __export(index_exports, {
63
73
  module.exports = __toCommonJS(index_exports);
64
74
 
65
75
  // src/config.ts
76
+ var MemoryTokenStorage = class {
77
+ constructor(seed) {
78
+ this.tokens = seed ?? null;
79
+ }
80
+ async getTokens() {
81
+ return this.tokens;
82
+ }
83
+ async setTokens(tokens) {
84
+ this.tokens = tokens;
85
+ }
86
+ };
66
87
  var DEFAULT_BASE_URL = "https://api.planescrm.com/api/v1";
67
88
  var DEFAULT_TIMEOUT = 3e4;
68
89
  var DEFAULT_MAX_RETRIES = 3;
90
+ var DEFAULT_TOKEN_REFRESH_SKEW_MS = 6e4;
69
91
 
70
92
  // src/errors.ts
71
- var BlueprintError = class extends Error {
93
+ var PlanesError = class extends Error {
72
94
  constructor(message, status, code) {
73
95
  super(message);
74
- this.name = "BlueprintError";
96
+ this.name = "PlanesError";
75
97
  this.status = status;
76
98
  this.code = code;
77
99
  }
78
100
  };
79
- var AuthenticationError = class extends BlueprintError {
101
+ var AuthenticationError = class extends PlanesError {
80
102
  constructor(message = "Authentication failed") {
81
103
  super(message, 401, "authentication_error");
82
104
  this.name = "AuthenticationError";
83
105
  }
84
106
  };
85
- var AuthorizationError = class extends BlueprintError {
107
+ var AuthorizationError = class extends PlanesError {
86
108
  constructor(message = "Insufficient permissions") {
87
109
  super(message, 403, "authorization_error");
88
110
  this.name = "AuthorizationError";
89
111
  }
90
112
  };
91
- var NotFoundError = class extends BlueprintError {
113
+ var NotFoundError = class extends PlanesError {
92
114
  constructor(message = "Resource not found") {
93
115
  super(message, 404, "not_found");
94
116
  this.name = "NotFoundError";
95
117
  }
96
118
  };
97
- var ValidationError = class extends BlueprintError {
119
+ var ValidationError = class extends PlanesError {
98
120
  constructor(message = "Validation failed", fieldErrors = []) {
99
121
  super(message, 400, "validation_error");
100
122
  this.name = "ValidationError";
101
123
  this.fieldErrors = fieldErrors;
102
124
  }
103
125
  };
104
- var RateLimitError = class extends BlueprintError {
126
+ var RateLimitError = class extends PlanesError {
105
127
  constructor(retryAfter = 1, message = "Rate limit exceeded") {
106
128
  super(message, 429, "rate_limit_error");
107
129
  this.name = "RateLimitError";
108
130
  this.retryAfter = retryAfter;
109
131
  }
110
132
  };
111
- var ServerError = class extends BlueprintError {
133
+ var ServerError = class extends PlanesError {
112
134
  constructor(message = "Internal server error") {
113
135
  super(message, 500, "server_error");
114
136
  this.name = "ServerError";
115
137
  }
116
138
  };
117
- var WebhookVerificationError = class extends BlueprintError {
139
+ var WebhookVerificationError = class extends PlanesError {
118
140
  constructor(message = "Webhook signature verification failed") {
119
141
  super(message, 400, "webhook_verification_error");
120
142
  this.name = "WebhookVerificationError";
121
143
  }
122
144
  };
123
- var OAuthError = class extends BlueprintError {
145
+ var OAuthError = class extends PlanesError {
124
146
  constructor(errorCode, message = "OAuth error") {
125
147
  super(message, 400, "oauth_error");
126
148
  this.name = "OAuthError";
@@ -128,6 +150,87 @@ var OAuthError = class extends BlueprintError {
128
150
  }
129
151
  };
130
152
 
153
+ // src/oauth.ts
154
+ var import_node_crypto = require("crypto");
155
+ function buildAuthorizeUrl(options) {
156
+ const state = options.state ?? (0, import_node_crypto.randomBytes)(16).toString("hex");
157
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
158
+ const url = new URL(`${baseUrl}/oauth/authorize`);
159
+ url.searchParams.set("client_id", options.clientId);
160
+ url.searchParams.set("redirect_uri", options.redirectUri);
161
+ url.searchParams.set("scope", options.scopes.join(" "));
162
+ url.searchParams.set("response_type", "code");
163
+ url.searchParams.set("state", state);
164
+ return url.toString();
165
+ }
166
+ async function exchangeCode(options) {
167
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
168
+ const url = `${baseUrl}/oauth/token`;
169
+ const body = new URLSearchParams({
170
+ grant_type: "authorization_code",
171
+ client_id: options.clientId,
172
+ client_secret: options.clientSecret,
173
+ code: options.code,
174
+ redirect_uri: options.redirectUri
175
+ });
176
+ const response = await fetch(url, {
177
+ method: "POST",
178
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
179
+ body: body.toString()
180
+ });
181
+ if (!response.ok) {
182
+ const errorBody = await response.json().catch(() => ({}));
183
+ const errorCode = errorBody["error"] ?? "unknown_error";
184
+ const errorDescription = errorBody["error_description"] ?? "Token exchange failed";
185
+ throw new OAuthError(errorCode, errorDescription);
186
+ }
187
+ return await response.json();
188
+ }
189
+ async function refreshAccessToken(options) {
190
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
191
+ const url = `${baseUrl}/oauth/token`;
192
+ const body = new URLSearchParams({
193
+ grant_type: "refresh_token",
194
+ client_id: options.clientId,
195
+ client_secret: options.clientSecret,
196
+ refresh_token: options.refreshToken
197
+ });
198
+ const response = await fetch(url, {
199
+ method: "POST",
200
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
201
+ body: body.toString()
202
+ });
203
+ if (!response.ok) {
204
+ const errorBody = await response.json().catch(() => ({}));
205
+ const errorCode = errorBody["error"] ?? "unknown_error";
206
+ const errorDescription = errorBody["error_description"] ?? "Token refresh failed";
207
+ throw new OAuthError(errorCode, errorDescription);
208
+ }
209
+ return await response.json();
210
+ }
211
+ async function clientCredentialsGrant(options) {
212
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
213
+ const url = `${baseUrl}/oauth/token`;
214
+ const body = new URLSearchParams({
215
+ grant_type: "client_credentials",
216
+ client_id: options.clientId,
217
+ client_secret: options.clientSecret,
218
+ scope: options.scopes.join(" ")
219
+ });
220
+ const response = await fetch(url, {
221
+ method: "POST",
222
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
223
+ body: body.toString()
224
+ });
225
+ if (!response.ok) {
226
+ const errorBody = await response.json().catch(() => ({}));
227
+ const errorCode = errorBody["error"] ?? "unknown_error";
228
+ const errorDescription = errorBody["error_description"] ?? "Client credentials grant failed";
229
+ throw new OAuthError(errorCode, errorDescription);
230
+ }
231
+ return await response.json();
232
+ }
233
+
131
234
  // src/http.ts
132
235
  var HttpClient = class {
133
236
  constructor(config) {
@@ -137,24 +240,35 @@ var HttpClient = class {
137
240
  this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
138
241
  this.retryOnRateLimit = config.retryOnRateLimit !== false;
139
242
  this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
243
+ if (config.clientId && config.clientSecret) {
244
+ this.oauth = {
245
+ clientId: config.clientId,
246
+ clientSecret: config.clientSecret,
247
+ tokenStorage: config.tokenStorage ?? new MemoryTokenStorage(),
248
+ skewMs: config.tokenRefreshSkewMs ?? DEFAULT_TOKEN_REFRESH_SKEW_MS,
249
+ scopes: config.scopes
250
+ };
251
+ }
140
252
  }
141
253
  async request(options) {
142
254
  const url = this.buildUrl(options.path, options.params);
143
- const headers = {
144
- "Content-Type": "application/json",
145
- ...this.getAuthHeaders(),
146
- ...options.headers
147
- };
148
- const fetchOptions = {
149
- method: options.method,
150
- headers,
151
- signal: AbortSignal.timeout(this.timeout)
152
- };
153
- if (options.body !== void 0) {
154
- fetchOptions.body = JSON.stringify(options.body);
155
- }
255
+ const bodyStr = options.body !== void 0 ? JSON.stringify(options.body) : void 0;
156
256
  let lastError;
257
+ let didRefreshOn401 = false;
157
258
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
259
+ const headers = {
260
+ "Content-Type": "application/json",
261
+ ...await this.resolveAuthHeaders(),
262
+ ...options.headers
263
+ };
264
+ const fetchOptions = {
265
+ method: options.method,
266
+ headers,
267
+ signal: AbortSignal.timeout(this.timeout)
268
+ };
269
+ if (bodyStr !== void 0) {
270
+ fetchOptions.body = bodyStr;
271
+ }
158
272
  const response = await fetch(url, fetchOptions);
159
273
  if (response.ok) {
160
274
  if (response.status === 204) {
@@ -167,6 +281,12 @@ var HttpClient = class {
167
281
  await this.sleep(retryAfter * 1e3);
168
282
  continue;
169
283
  }
284
+ if (response.status === 401 && this.oauth && !didRefreshOn401) {
285
+ didRefreshOn401 = true;
286
+ await this.refreshTokens();
287
+ attempt--;
288
+ continue;
289
+ }
170
290
  lastError = await this.mapError(response);
171
291
  if (response.status !== 429 || !this.retryOnRateLimit) {
172
292
  throw lastError;
@@ -183,6 +303,76 @@ var HttpClient = class {
183
303
  }
184
304
  return {};
185
305
  }
306
+ /**
307
+ * Resolve the Authorization header for the current request. For the static
308
+ * accessToken/apiKey modes this is {@link getAuthHeaders}. For the
309
+ * OAuth-managed path it reads the stored token, proactively refreshing when
310
+ * the token is within the skew window of expiry.
311
+ */
312
+ async resolveAuthHeaders() {
313
+ if (!this.oauth) {
314
+ return this.getAuthHeaders();
315
+ }
316
+ const tokens = await this.oauth.tokenStorage.getTokens();
317
+ if (!tokens) {
318
+ return {};
319
+ }
320
+ if (tokens.expiresAt - Date.now() <= this.oauth.skewMs) {
321
+ const refreshed = await this.refreshTokens();
322
+ return { Authorization: `Bearer ${refreshed.accessToken}` };
323
+ }
324
+ return { Authorization: `Bearer ${tokens.accessToken}` };
325
+ }
326
+ /**
327
+ * Refresh the OAuth access token via `refreshAccessToken` and persist the new
328
+ * tokens through the configured {@link TokenStorage}. Concurrent callers share
329
+ * a single in-flight refresh.
330
+ */
331
+ async refreshTokens() {
332
+ if (!this.oauth) {
333
+ throw new AuthenticationError("Token refresh requires the OAuth clientId/clientSecret path");
334
+ }
335
+ if (this.refreshInFlight) {
336
+ return this.refreshInFlight;
337
+ }
338
+ const oauth = this.oauth;
339
+ this.refreshInFlight = (async () => {
340
+ const current = await oauth.tokenStorage.getTokens();
341
+ if (!current?.refreshToken) {
342
+ const response2 = await clientCredentialsGrant({
343
+ baseUrl: this.baseUrl,
344
+ clientId: oauth.clientId,
345
+ clientSecret: oauth.clientSecret,
346
+ scopes: oauth.scopes ?? []
347
+ });
348
+ const next2 = {
349
+ accessToken: response2.access_token,
350
+ refreshToken: response2.refresh_token ?? null,
351
+ expiresAt: Date.now() + response2.expires_in * 1e3
352
+ };
353
+ await oauth.tokenStorage.setTokens(next2);
354
+ return next2;
355
+ }
356
+ const response = await refreshAccessToken({
357
+ baseUrl: this.baseUrl,
358
+ clientId: oauth.clientId,
359
+ clientSecret: oauth.clientSecret,
360
+ refreshToken: current.refreshToken
361
+ });
362
+ const next = {
363
+ accessToken: response.access_token,
364
+ refreshToken: response.refresh_token || current.refreshToken,
365
+ expiresAt: Date.now() + response.expires_in * 1e3
366
+ };
367
+ await oauth.tokenStorage.setTokens(next);
368
+ return next;
369
+ })();
370
+ try {
371
+ return await this.refreshInFlight;
372
+ } finally {
373
+ this.refreshInFlight = void 0;
374
+ }
375
+ }
186
376
  buildUrl(path, params) {
187
377
  const url = new URL(`${this.baseUrl}${path}`);
188
378
  if (params) {
@@ -230,7 +420,7 @@ var HttpClient = class {
230
420
  if (response.status >= 500) {
231
421
  return new ServerError(message);
232
422
  }
233
- return new BlueprintError(message, response.status, "unknown_error");
423
+ return new PlanesError(message, response.status, "unknown_error");
234
424
  }
235
425
  }
236
426
  sleep(ms) {
@@ -357,14 +547,207 @@ var InventoryResource = class extends BaseResource {
357
547
  }
358
548
  };
359
549
 
550
+ // src/resources/contacts.ts
551
+ var ContactChannelResource = class {
552
+ constructor(http2, contactId) {
553
+ this.http = http2;
554
+ this.basePath = `/contacts/${contactId}/channels`;
555
+ }
556
+ /** Returns `{ channel, duplicateOf }` — `duplicateOf` is the dedup signal. */
557
+ async create(data) {
558
+ return this.http.request({
559
+ method: "POST",
560
+ path: this.basePath,
561
+ body: data
562
+ });
563
+ }
564
+ async update(channelId, data) {
565
+ return this.http.request({
566
+ method: "PATCH",
567
+ path: `${this.basePath}/${channelId}`,
568
+ body: data
569
+ });
570
+ }
571
+ async delete(channelId) {
572
+ await this.http.request({
573
+ method: "DELETE",
574
+ path: `${this.basePath}/${channelId}`
575
+ });
576
+ }
577
+ };
578
+ var ContactResource = class extends BaseResource {
579
+ constructor(http2) {
580
+ super(http2, "/contacts");
581
+ }
582
+ async merge(winnerId, loserId) {
583
+ return this.http.request({
584
+ method: "POST",
585
+ path: `/contacts/${winnerId}/merge/${loserId}`
586
+ });
587
+ }
588
+ channels(contactId) {
589
+ return new ContactChannelResource(this.http, contactId);
590
+ }
591
+ };
592
+
593
+ // src/resources/activities.ts
594
+ var ActivityResource = class {
595
+ constructor(http2) {
596
+ this.http = http2;
597
+ }
598
+ async create(data) {
599
+ return this.http.request({
600
+ method: "POST",
601
+ path: "/activities",
602
+ body: data
603
+ });
604
+ }
605
+ async list(params) {
606
+ return this.http.request({
607
+ method: "GET",
608
+ path: "/activities",
609
+ params
610
+ });
611
+ }
612
+ async listForEntity(entityType, entityId, params) {
613
+ return this.http.request({
614
+ method: "GET",
615
+ path: `/${entityType}/${entityId}/activities`,
616
+ params
617
+ });
618
+ }
619
+ };
620
+
621
+ // src/resources/tags.ts
622
+ var TagResource = class extends BaseResource {
623
+ constructor(http2) {
624
+ super(http2, "/tags");
625
+ }
626
+ /** `TagsService.setEntityTags()` returns the final tag set wrapped in `{ tags }`. */
627
+ async setEntityTags(entityType, entityId, data) {
628
+ return this.http.request({
629
+ method: "PUT",
630
+ path: `/entities/${entityType}/${entityId}/tags`,
631
+ body: data
632
+ });
633
+ }
634
+ async getEntityTags(entityType, entityId) {
635
+ return this.http.request({
636
+ method: "GET",
637
+ path: `/entities/${entityType}/${entityId}/tags`
638
+ });
639
+ }
640
+ async getEntities(tagId, params) {
641
+ return this.http.request({
642
+ method: "GET",
643
+ path: `/tags/${tagId}/entities`,
644
+ params
645
+ });
646
+ }
647
+ };
648
+
649
+ // src/webhooks.ts
650
+ var import_node_crypto2 = require("crypto");
651
+ function verifyWebhookSignature(payload, signature, secret) {
652
+ if (!signature || !secret || !payload) {
653
+ return false;
654
+ }
655
+ const prefix = "sha256=";
656
+ if (!signature.startsWith(prefix)) {
657
+ return false;
658
+ }
659
+ const receivedHex = signature.slice(prefix.length);
660
+ if (!receivedHex) {
661
+ return false;
662
+ }
663
+ const expectedHmac = (0, import_node_crypto2.createHmac)("sha256", secret).update(payload).digest("hex");
664
+ const receivedBuf = Buffer.from(receivedHex, "hex");
665
+ const expectedBuf = Buffer.from(expectedHmac, "hex");
666
+ if (receivedBuf.length !== expectedBuf.length) {
667
+ return false;
668
+ }
669
+ return (0, import_node_crypto2.timingSafeEqual)(receivedBuf, expectedBuf);
670
+ }
671
+ function parseWebhookEvent(body, signature, secret) {
672
+ if (!verifyWebhookSignature(body, signature, secret)) {
673
+ throw new WebhookVerificationError();
674
+ }
675
+ return JSON.parse(body);
676
+ }
677
+
678
+ // src/webhook-router.ts
679
+ var WebhookRouter = class {
680
+ constructor() {
681
+ /** pattern (composite key / entity wildcard / '*') → registered handlers. */
682
+ this.handlers = /* @__PURE__ */ new Map();
683
+ }
684
+ /**
685
+ * Register a handler for an event pattern.
686
+ *
687
+ * @param pattern - `"{entity_type}.{event_type}"` (e.g. `opportunity.created`),
688
+ * an entity wildcard `"{entity_type}.*"` (e.g. `opportunity.*`), or the
689
+ * global wildcard `"*"`. Matching is exact-string against the derived key;
690
+ * see the class docs for the dotted-`event_type` shape.
691
+ * @param handler - Invoked with the full {@link WebhookDelivery} on a match.
692
+ * @returns `this`, for chaining.
693
+ */
694
+ on(pattern, handler) {
695
+ const existing = this.handlers.get(pattern);
696
+ if (existing) {
697
+ existing.push(handler);
698
+ } else {
699
+ this.handlers.set(pattern, [handler]);
700
+ }
701
+ return this;
702
+ }
703
+ /**
704
+ * Verify, parse, and dispatch a webhook delivery.
705
+ *
706
+ * The signature is verified first: on failure this throws
707
+ * {@link WebhookVerificationError} and dispatches nothing. On success it
708
+ * dispatches to every matching handler — specific key, then entity wildcard,
709
+ * then global — awaiting async handlers before resolving.
710
+ *
711
+ * @param input - The raw `{ body, signature, secret }`.
712
+ * @returns The parsed {@link WebhookDelivery}.
713
+ * @throws WebhookVerificationError if the signature is invalid.
714
+ */
715
+ async process(input) {
716
+ const delivery = parseWebhookEvent(input.body, input.signature, input.secret);
717
+ const { entity_type, event_type } = delivery.event;
718
+ const patterns = [`${entity_type}.${event_type}`, `${entity_type}.*`, "*"];
719
+ for (const pattern of patterns) {
720
+ const matched = this.handlers.get(pattern);
721
+ if (!matched) continue;
722
+ for (const handler of matched) {
723
+ await handler(delivery);
724
+ }
725
+ }
726
+ return delivery;
727
+ }
728
+ };
729
+
360
730
  // src/client.ts
361
- var BlueprintClient = class {
731
+ var PlanesClient = class {
362
732
  constructor(config) {
363
- if (!config.accessToken && !config.apiKey) {
364
- throw new Error("BlueprintClient requires either accessToken or apiKey");
733
+ const hasOAuthCreds = Boolean(config.clientId) || Boolean(config.clientSecret);
734
+ const modes = [
735
+ config.accessToken ? "accessToken" : null,
736
+ config.apiKey ? "apiKey" : null,
737
+ hasOAuthCreds ? "clientId+clientSecret" : null
738
+ ].filter((m) => m !== null);
739
+ if (modes.length === 0) {
740
+ throw new Error(
741
+ "PlanesClient requires one auth mode: accessToken, apiKey, or clientId + clientSecret"
742
+ );
365
743
  }
366
- if (config.accessToken && config.apiKey) {
367
- throw new Error("BlueprintClient requires either accessToken or apiKey, not both");
744
+ if (modes.length > 1) {
745
+ throw new Error(
746
+ `PlanesClient requires exactly one auth mode, not multiple (got: ${modes.join(", ")})`
747
+ );
748
+ }
749
+ if (hasOAuthCreds && (!config.clientId || !config.clientSecret)) {
750
+ throw new Error("PlanesClient OAuth mode requires both clientId and clientSecret");
368
751
  }
369
752
  this.http = new HttpClient(config);
370
753
  }
@@ -422,17 +805,134 @@ var BlueprintClient = class {
422
805
  }
423
806
  return this._inventory;
424
807
  }
808
+ get contacts() {
809
+ if (!this._contacts) {
810
+ this._contacts = new ContactResource(this.http);
811
+ }
812
+ return this._contacts;
813
+ }
814
+ get activities() {
815
+ if (!this._activities) {
816
+ this._activities = new ActivityResource(this.http);
817
+ }
818
+ return this._activities;
819
+ }
820
+ get tags() {
821
+ if (!this._tags) {
822
+ this._tags = new TagResource(this.http);
823
+ }
824
+ return this._tags;
825
+ }
826
+ /**
827
+ * Ergonomic webhook event router — `client.webhooks.on(pattern, handler)`
828
+ * plus `client.webhooks.process({ body, signature, secret })`. Auth-independent
829
+ * (the signing secret is supplied per `.process()` call), so it is created
830
+ * lazily without the HTTP client. See {@link WebhookRouter}.
831
+ */
832
+ get webhooks() {
833
+ if (!this._webhooks) {
834
+ this._webhooks = new WebhookRouter();
835
+ }
836
+ return this._webhooks;
837
+ }
425
838
  };
426
839
 
427
840
  // src/agent.ts
428
841
  var http = __toESM(require("http"));
429
- var Blueprint = {
842
+ function createActionServer(handlerMap, options) {
843
+ const port = options?.port ?? 3e3;
844
+ const basePath = options?.path ?? "/actions";
845
+ const onError = options?.onError;
846
+ const server = http.createServer(async (req, res) => {
847
+ if (req.method !== "POST" || req.url !== basePath) {
848
+ res.writeHead(404, { "Content-Type": "application/json" });
849
+ res.end(JSON.stringify({ error: "Not found" }));
850
+ return;
851
+ }
852
+ try {
853
+ const body = await readBody(req);
854
+ let envelope;
855
+ try {
856
+ envelope = JSON.parse(body);
857
+ } catch {
858
+ res.writeHead(400, { "Content-Type": "application/json" });
859
+ res.end(JSON.stringify({ error: "Malformed request body" }));
860
+ return;
861
+ }
862
+ const wire = envelope?.action_context;
863
+ const actionType = wire?.action_type;
864
+ if (!wire || !actionType) {
865
+ res.writeHead(400, { "Content-Type": "application/json" });
866
+ res.end(
867
+ JSON.stringify({ error: "Missing action_context.action_type in request body" })
868
+ );
869
+ return;
870
+ }
871
+ const handler = handlerMap.get(actionType);
872
+ if (!handler) {
873
+ res.writeHead(404, { "Content-Type": "application/json" });
874
+ res.end(
875
+ JSON.stringify({
876
+ error: `No handler registered for action type: ${actionType}`
877
+ })
878
+ );
879
+ return;
880
+ }
881
+ const requestIdHeader = req.headers["x-planes-request-id"];
882
+ const requestId = Array.isArray(requestIdHeader) ? requestIdHeader[0] : requestIdHeader;
883
+ const context = {
884
+ action_id: wire.action_id,
885
+ action_type: actionType,
886
+ entity_type: wire.entity_type,
887
+ entity_id: wire.entity_id,
888
+ tenant_id: wire.tenant_id,
889
+ data: wire.data ?? {},
890
+ stage: wire.stage ?? null,
891
+ metadata: wire.metadata,
892
+ callback_token: envelope.callback_token,
893
+ callback_base_url: envelope.callback_base_url,
894
+ // Only set request_id when the header was present (exactOptionalPropertyTypes).
895
+ ...requestId !== void 0 ? { request_id: requestId } : {}
896
+ };
897
+ let result;
898
+ try {
899
+ result = await handler.execute(context);
900
+ } catch (err) {
901
+ const errorMessage = err instanceof Error ? err.message : "Unknown handler error";
902
+ if (onError && err instanceof Error) {
903
+ onError(err);
904
+ }
905
+ result = {
906
+ status: "failed",
907
+ error: errorMessage,
908
+ retryable: false
909
+ };
910
+ }
911
+ res.writeHead(200, { "Content-Type": "application/json" });
912
+ res.end(JSON.stringify(result));
913
+ } catch (err) {
914
+ if (onError && err instanceof Error) {
915
+ onError(err);
916
+ }
917
+ res.writeHead(500, { "Content-Type": "application/json" });
918
+ res.end(JSON.stringify({ error: "Internal server error" }));
919
+ }
920
+ });
921
+ server.listen(port);
922
+ const shutdown = () => {
923
+ server.close();
924
+ };
925
+ process.on("SIGTERM", shutdown);
926
+ process.on("SIGINT", shutdown);
927
+ return server;
928
+ }
929
+ var Planes = {
430
930
  /**
431
931
  * Register action handlers and start an HTTP server to receive action requests.
432
932
  *
433
933
  * @example
434
934
  * ```ts
435
- * Blueprint.register([
935
+ * Planes.register([
436
936
  * {
437
937
  * type: 'send_email',
438
938
  * async execute(ctx) {
@@ -444,77 +944,45 @@ var Blueprint = {
444
944
  * ```
445
945
  */
446
946
  register(handlers, options) {
447
- const port = options?.port ?? 3e3;
448
- const basePath = options?.path ?? "/actions";
449
- const onError = options?.onError;
450
947
  const handlerMap = /* @__PURE__ */ new Map();
451
948
  for (const handler of handlers) {
452
949
  handlerMap.set(handler.type, handler);
453
950
  }
454
- const server = http.createServer(async (req, res) => {
455
- if (req.method !== "POST" || req.url !== basePath) {
456
- res.writeHead(404, { "Content-Type": "application/json" });
457
- res.end(JSON.stringify({ error: "Not found" }));
458
- return;
459
- }
460
- try {
461
- const body = await readBody(req);
462
- let context;
463
- try {
464
- context = JSON.parse(body);
465
- } catch {
466
- res.writeHead(400, { "Content-Type": "application/json" });
467
- res.end(JSON.stringify({ error: "Malformed request body" }));
468
- return;
469
- }
470
- if (!context.action?.type) {
471
- res.writeHead(400, { "Content-Type": "application/json" });
472
- res.end(JSON.stringify({ error: "Missing action.type in request body" }));
473
- return;
474
- }
475
- const handler = handlerMap.get(context.action.type);
476
- if (!handler) {
477
- res.writeHead(404, { "Content-Type": "application/json" });
478
- res.end(
479
- JSON.stringify({
480
- error: `No handler registered for action type: ${context.action.type}`
481
- })
482
- );
483
- return;
484
- }
485
- let result;
486
- try {
487
- result = await handler.execute(context);
488
- } catch (err) {
489
- const errorMessage = err instanceof Error ? err.message : "Unknown handler error";
490
- if (onError && err instanceof Error) {
491
- onError(err);
492
- }
493
- result = {
494
- status: "failed",
495
- error: errorMessage,
496
- retryable: false
497
- };
498
- }
499
- res.writeHead(200, { "Content-Type": "application/json" });
500
- res.end(JSON.stringify(result));
501
- } catch (err) {
502
- if (onError && err instanceof Error) {
503
- onError(err);
504
- }
505
- res.writeHead(500, { "Content-Type": "application/json" });
506
- res.end(JSON.stringify({ error: "Internal server error" }));
507
- }
508
- });
509
- server.listen(port);
510
- const shutdown = () => {
511
- server.close();
512
- };
513
- process.on("SIGTERM", shutdown);
514
- process.on("SIGINT", shutdown);
515
- return server;
951
+ return createActionServer(handlerMap, options);
952
+ }
953
+ };
954
+ var AgentApp = class {
955
+ constructor() {
956
+ this.handlers = /* @__PURE__ */ new Map();
957
+ }
958
+ /**
959
+ * Register a handler for an action type. Accepts a bare `execute` function
960
+ * (the caller does not build the `{ type, execute }` object).
961
+ *
962
+ * @param type - The action type this handler responds to.
963
+ * @param execute - The handler function invoked with the {@link ActionContext}.
964
+ * @returns `this`, for chaining.
965
+ */
966
+ registerAction(type, execute) {
967
+ this.handlers.set(type, { type, execute });
968
+ return this;
969
+ }
970
+ /**
971
+ * Start an HTTP server that dispatches incoming action requests to the
972
+ * registered handlers. Delegates to the same server factory as
973
+ * {@link Planes.register}.
974
+ *
975
+ * The handler set is snapshotted at call time, so registering further actions
976
+ * after `listen()` does not mutate the running server (parity with
977
+ * `Planes.register`, which snapshots its array).
978
+ */
979
+ listen(options) {
980
+ return createActionServer(new Map(this.handlers), options);
516
981
  }
517
982
  };
983
+ function createAgentApp() {
984
+ return new AgentApp();
985
+ }
518
986
  function readBody(req) {
519
987
  return new Promise((resolve, reject) => {
520
988
  const chunks = [];
@@ -523,121 +991,43 @@ function readBody(req) {
523
991
  req.on("error", reject);
524
992
  });
525
993
  }
526
-
527
- // src/webhooks.ts
528
- var import_node_crypto = require("crypto");
529
- function verifyWebhookSignature(payload, signature, secret) {
530
- if (!signature || !secret || !payload) {
531
- return false;
532
- }
533
- const prefix = "sha256=";
534
- if (!signature.startsWith(prefix)) {
535
- return false;
536
- }
537
- const receivedHex = signature.slice(prefix.length);
538
- if (!receivedHex) {
539
- return false;
540
- }
541
- const expectedHmac = (0, import_node_crypto.createHmac)("sha256", secret).update(payload).digest("hex");
542
- const receivedBuf = Buffer.from(receivedHex, "hex");
543
- const expectedBuf = Buffer.from(expectedHmac, "hex");
544
- if (receivedBuf.length !== expectedBuf.length) {
545
- return false;
546
- }
547
- return (0, import_node_crypto.timingSafeEqual)(receivedBuf, expectedBuf);
548
- }
549
- function parseWebhookEvent(body, signature, secret) {
550
- if (!verifyWebhookSignature(body, signature, secret)) {
551
- throw new WebhookVerificationError();
552
- }
553
- return JSON.parse(body);
554
- }
555
-
556
- // src/oauth.ts
557
- var import_node_crypto2 = require("crypto");
558
- function buildAuthorizeUrl(options) {
559
- const state = options.state ?? (0, import_node_crypto2.randomBytes)(16).toString("hex");
560
- const baseUrl = options.baseUrl.replace(/\/+$/, "");
561
- const url = new URL(`${baseUrl}/oauth/authorize`);
562
- url.searchParams.set("client_id", options.clientId);
563
- url.searchParams.set("redirect_uri", options.redirectUri);
564
- url.searchParams.set("scope", options.scopes.join(" "));
565
- url.searchParams.set("response_type", "code");
566
- url.searchParams.set("state", state);
567
- return url.toString();
568
- }
569
- async function exchangeCode(options) {
570
- const baseUrl = options.baseUrl.replace(/\/+$/, "");
571
- const url = `${baseUrl}/oauth/token`;
572
- const body = new URLSearchParams({
573
- grant_type: "authorization_code",
574
- client_id: options.clientId,
575
- client_secret: options.clientSecret,
576
- code: options.code,
577
- redirect_uri: options.redirectUri
578
- });
579
- const response = await fetch(url, {
580
- method: "POST",
581
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
582
- body: body.toString()
583
- });
584
- if (!response.ok) {
585
- const errorBody = await response.json().catch(() => ({}));
586
- const errorCode = errorBody["error"] ?? "unknown_error";
587
- const errorDescription = errorBody["error_description"] ?? "Token exchange failed";
588
- throw new OAuthError(errorCode, errorDescription);
589
- }
590
- return await response.json();
591
- }
592
- async function refreshAccessToken(options) {
593
- const baseUrl = options.baseUrl.replace(/\/+$/, "");
594
- const url = `${baseUrl}/oauth/token`;
595
- const body = new URLSearchParams({
596
- grant_type: "refresh_token",
597
- client_id: options.clientId,
598
- client_secret: options.clientSecret,
599
- refresh_token: options.refreshToken
600
- });
601
- const response = await fetch(url, {
602
- method: "POST",
603
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
604
- body: body.toString()
605
- });
606
- if (!response.ok) {
607
- const errorBody = await response.json().catch(() => ({}));
608
- const errorCode = errorBody["error"] ?? "unknown_error";
609
- const errorDescription = errorBody["error_description"] ?? "Token refresh failed";
610
- throw new OAuthError(errorCode, errorDescription);
611
- }
612
- return await response.json();
613
- }
614
994
  // Annotate the CommonJS export names for ESM import in node:
615
995
  0 && (module.exports = {
996
+ ActivityResource,
997
+ AgentApp,
616
998
  AuthenticationError,
617
999
  AuthorizationError,
618
1000
  BaseResource,
619
- Blueprint,
620
- BlueprintClient,
621
- BlueprintError,
622
1001
  CalendarEventResource,
1002
+ ContactChannelResource,
1003
+ ContactResource,
623
1004
  ContractResource,
624
1005
  CustomerResource,
625
1006
  DEFAULT_BASE_URL,
626
1007
  DEFAULT_MAX_RETRIES,
627
1008
  DEFAULT_TIMEOUT,
1009
+ DEFAULT_TOKEN_REFRESH_SKEW_MS,
628
1010
  EstimateResource,
629
1011
  InventoryResource,
630
1012
  InvoiceResource,
631
1013
  JobResource,
1014
+ MemoryTokenStorage,
632
1015
  NotFoundError,
633
1016
  OAuthError,
634
1017
  OpportunityResource,
1018
+ Planes,
1019
+ PlanesClient,
1020
+ PlanesError,
635
1021
  PropertyResource,
636
1022
  RateLimitError,
637
1023
  ServerError,
1024
+ TagResource,
638
1025
  ValidationError,
1026
+ WebhookRouter,
639
1027
  WebhookVerificationError,
640
1028
  buildAuthorizeUrl,
1029
+ clientCredentialsGrant,
1030
+ createAgentApp,
641
1031
  exchangeCode,
642
1032
  parseWebhookEvent,
643
1033
  refreshAccessToken,