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