@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.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 BlueprintError = class extends Error {
19
+ var PlanesError = class extends Error {
8
20
  constructor(message, status, code) {
9
21
  super(message);
10
- this.name = "BlueprintError";
22
+ this.name = "PlanesError";
11
23
  this.status = status;
12
24
  this.code = code;
13
25
  }
14
26
  };
15
- var AuthenticationError = class extends BlueprintError {
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 BlueprintError {
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 BlueprintError {
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 BlueprintError {
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 BlueprintError {
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 BlueprintError {
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 BlueprintError {
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 BlueprintError {
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,65 @@ 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
+
67
138
  // src/http.ts
68
139
  var HttpClient = class {
69
140
  constructor(config) {
@@ -73,24 +144,34 @@ var HttpClient = class {
73
144
  this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
74
145
  this.retryOnRateLimit = config.retryOnRateLimit !== false;
75
146
  this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
147
+ if (config.clientId && config.clientSecret) {
148
+ this.oauth = {
149
+ clientId: config.clientId,
150
+ clientSecret: config.clientSecret,
151
+ tokenStorage: config.tokenStorage ?? new MemoryTokenStorage(),
152
+ skewMs: config.tokenRefreshSkewMs ?? DEFAULT_TOKEN_REFRESH_SKEW_MS
153
+ };
154
+ }
76
155
  }
77
156
  async request(options) {
78
157
  const url = this.buildUrl(options.path, options.params);
79
- const headers = {
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
- }
158
+ const bodyStr = options.body !== void 0 ? JSON.stringify(options.body) : void 0;
92
159
  let lastError;
160
+ let didRefreshOn401 = false;
93
161
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
162
+ const headers = {
163
+ "Content-Type": "application/json",
164
+ ...await this.resolveAuthHeaders(),
165
+ ...options.headers
166
+ };
167
+ const fetchOptions = {
168
+ method: options.method,
169
+ headers,
170
+ signal: AbortSignal.timeout(this.timeout)
171
+ };
172
+ if (bodyStr !== void 0) {
173
+ fetchOptions.body = bodyStr;
174
+ }
94
175
  const response = await fetch(url, fetchOptions);
95
176
  if (response.ok) {
96
177
  if (response.status === 204) {
@@ -103,6 +184,12 @@ var HttpClient = class {
103
184
  await this.sleep(retryAfter * 1e3);
104
185
  continue;
105
186
  }
187
+ if (response.status === 401 && this.oauth && !didRefreshOn401) {
188
+ didRefreshOn401 = true;
189
+ await this.refreshTokens();
190
+ attempt--;
191
+ continue;
192
+ }
106
193
  lastError = await this.mapError(response);
107
194
  if (response.status !== 429 || !this.retryOnRateLimit) {
108
195
  throw lastError;
@@ -119,6 +206,64 @@ var HttpClient = class {
119
206
  }
120
207
  return {};
121
208
  }
209
+ /**
210
+ * Resolve the Authorization header for the current request. For the static
211
+ * accessToken/apiKey modes this is {@link getAuthHeaders}. For the
212
+ * OAuth-managed path it reads the stored token, proactively refreshing when
213
+ * the token is within the skew window of expiry.
214
+ */
215
+ async resolveAuthHeaders() {
216
+ if (!this.oauth) {
217
+ return this.getAuthHeaders();
218
+ }
219
+ const tokens = await this.oauth.tokenStorage.getTokens();
220
+ if (!tokens) {
221
+ return {};
222
+ }
223
+ if (tokens.expiresAt - Date.now() <= this.oauth.skewMs) {
224
+ const refreshed = await this.refreshTokens();
225
+ return { Authorization: `Bearer ${refreshed.accessToken}` };
226
+ }
227
+ return { Authorization: `Bearer ${tokens.accessToken}` };
228
+ }
229
+ /**
230
+ * Refresh the OAuth access token via `refreshAccessToken` and persist the new
231
+ * tokens through the configured {@link TokenStorage}. Concurrent callers share
232
+ * a single in-flight refresh.
233
+ */
234
+ async refreshTokens() {
235
+ if (!this.oauth) {
236
+ throw new AuthenticationError("Token refresh requires the OAuth clientId/clientSecret path");
237
+ }
238
+ if (this.refreshInFlight) {
239
+ return this.refreshInFlight;
240
+ }
241
+ const oauth = this.oauth;
242
+ this.refreshInFlight = (async () => {
243
+ const current = await oauth.tokenStorage.getTokens();
244
+ if (!current?.refreshToken) {
245
+ throw new AuthenticationError("No refresh token available to refresh the access token");
246
+ }
247
+ const response = await refreshAccessToken({
248
+ baseUrl: this.baseUrl,
249
+ clientId: oauth.clientId,
250
+ clientSecret: oauth.clientSecret,
251
+ refreshToken: current.refreshToken
252
+ });
253
+ const next = {
254
+ accessToken: response.access_token,
255
+ refreshToken: response.refresh_token || current.refreshToken,
256
+ expiresAt: Date.now() + response.expires_in * 1e3
257
+ };
258
+ await oauth.tokenStorage.setTokens(next);
259
+ return next;
260
+ })();
261
+ try {
262
+ return await this.refreshInFlight;
263
+ } finally {
264
+ this.refreshInFlight = void 0;
265
+ }
266
+ }
122
267
  buildUrl(path, params) {
123
268
  const url = new URL(`${this.baseUrl}${path}`);
124
269
  if (params) {
@@ -166,7 +311,7 @@ var HttpClient = class {
166
311
  if (response.status >= 500) {
167
312
  return new ServerError(message);
168
313
  }
169
- return new BlueprintError(message, response.status, "unknown_error");
314
+ return new PlanesError(message, response.status, "unknown_error");
170
315
  }
171
316
  }
172
317
  sleep(ms) {
@@ -293,14 +438,207 @@ var InventoryResource = class extends BaseResource {
293
438
  }
294
439
  };
295
440
 
441
+ // src/resources/contacts.ts
442
+ var ContactChannelResource = class {
443
+ constructor(http2, contactId) {
444
+ this.http = http2;
445
+ this.basePath = `/contacts/${contactId}/channels`;
446
+ }
447
+ /** Returns `{ channel, duplicateOf }` — `duplicateOf` is the dedup signal. */
448
+ async create(data) {
449
+ return this.http.request({
450
+ method: "POST",
451
+ path: this.basePath,
452
+ body: data
453
+ });
454
+ }
455
+ async update(channelId, data) {
456
+ return this.http.request({
457
+ method: "PATCH",
458
+ path: `${this.basePath}/${channelId}`,
459
+ body: data
460
+ });
461
+ }
462
+ async delete(channelId) {
463
+ await this.http.request({
464
+ method: "DELETE",
465
+ path: `${this.basePath}/${channelId}`
466
+ });
467
+ }
468
+ };
469
+ var ContactResource = class extends BaseResource {
470
+ constructor(http2) {
471
+ super(http2, "/contacts");
472
+ }
473
+ async merge(winnerId, loserId) {
474
+ return this.http.request({
475
+ method: "POST",
476
+ path: `/contacts/${winnerId}/merge/${loserId}`
477
+ });
478
+ }
479
+ channels(contactId) {
480
+ return new ContactChannelResource(this.http, contactId);
481
+ }
482
+ };
483
+
484
+ // src/resources/activities.ts
485
+ var ActivityResource = class {
486
+ constructor(http2) {
487
+ this.http = http2;
488
+ }
489
+ async create(data) {
490
+ return this.http.request({
491
+ method: "POST",
492
+ path: "/activities",
493
+ body: data
494
+ });
495
+ }
496
+ async list(params) {
497
+ return this.http.request({
498
+ method: "GET",
499
+ path: "/activities",
500
+ params
501
+ });
502
+ }
503
+ async listForEntity(entityType, entityId, params) {
504
+ return this.http.request({
505
+ method: "GET",
506
+ path: `/${entityType}/${entityId}/activities`,
507
+ params
508
+ });
509
+ }
510
+ };
511
+
512
+ // src/resources/tags.ts
513
+ var TagResource = class extends BaseResource {
514
+ constructor(http2) {
515
+ super(http2, "/tags");
516
+ }
517
+ /** `TagsService.setEntityTags()` returns the final tag set wrapped in `{ tags }`. */
518
+ async setEntityTags(entityType, entityId, data) {
519
+ return this.http.request({
520
+ method: "PUT",
521
+ path: `/entities/${entityType}/${entityId}/tags`,
522
+ body: data
523
+ });
524
+ }
525
+ async getEntityTags(entityType, entityId) {
526
+ return this.http.request({
527
+ method: "GET",
528
+ path: `/entities/${entityType}/${entityId}/tags`
529
+ });
530
+ }
531
+ async getEntities(tagId, params) {
532
+ return this.http.request({
533
+ method: "GET",
534
+ path: `/tags/${tagId}/entities`,
535
+ params
536
+ });
537
+ }
538
+ };
539
+
540
+ // src/webhooks.ts
541
+ import { createHmac, timingSafeEqual } from "crypto";
542
+ function verifyWebhookSignature(payload, signature, secret) {
543
+ if (!signature || !secret || !payload) {
544
+ return false;
545
+ }
546
+ const prefix = "sha256=";
547
+ if (!signature.startsWith(prefix)) {
548
+ return false;
549
+ }
550
+ const receivedHex = signature.slice(prefix.length);
551
+ if (!receivedHex) {
552
+ return false;
553
+ }
554
+ const expectedHmac = createHmac("sha256", secret).update(payload).digest("hex");
555
+ const receivedBuf = Buffer.from(receivedHex, "hex");
556
+ const expectedBuf = Buffer.from(expectedHmac, "hex");
557
+ if (receivedBuf.length !== expectedBuf.length) {
558
+ return false;
559
+ }
560
+ return timingSafeEqual(receivedBuf, expectedBuf);
561
+ }
562
+ function parseWebhookEvent(body, signature, secret) {
563
+ if (!verifyWebhookSignature(body, signature, secret)) {
564
+ throw new WebhookVerificationError();
565
+ }
566
+ return JSON.parse(body);
567
+ }
568
+
569
+ // src/webhook-router.ts
570
+ var WebhookRouter = class {
571
+ constructor() {
572
+ /** pattern (composite key / entity wildcard / '*') → registered handlers. */
573
+ this.handlers = /* @__PURE__ */ new Map();
574
+ }
575
+ /**
576
+ * Register a handler for an event pattern.
577
+ *
578
+ * @param pattern - `"{entity_type}.{event_type}"` (e.g. `opportunity.created`),
579
+ * an entity wildcard `"{entity_type}.*"` (e.g. `opportunity.*`), or the
580
+ * global wildcard `"*"`. Matching is exact-string against the derived key;
581
+ * see the class docs for the dotted-`event_type` shape.
582
+ * @param handler - Invoked with the full {@link WebhookDelivery} on a match.
583
+ * @returns `this`, for chaining.
584
+ */
585
+ on(pattern, handler) {
586
+ const existing = this.handlers.get(pattern);
587
+ if (existing) {
588
+ existing.push(handler);
589
+ } else {
590
+ this.handlers.set(pattern, [handler]);
591
+ }
592
+ return this;
593
+ }
594
+ /**
595
+ * Verify, parse, and dispatch a webhook delivery.
596
+ *
597
+ * The signature is verified first: on failure this throws
598
+ * {@link WebhookVerificationError} and dispatches nothing. On success it
599
+ * dispatches to every matching handler — specific key, then entity wildcard,
600
+ * then global — awaiting async handlers before resolving.
601
+ *
602
+ * @param input - The raw `{ body, signature, secret }`.
603
+ * @returns The parsed {@link WebhookDelivery}.
604
+ * @throws WebhookVerificationError if the signature is invalid.
605
+ */
606
+ async process(input) {
607
+ const delivery = parseWebhookEvent(input.body, input.signature, input.secret);
608
+ const { entity_type, event_type } = delivery.event;
609
+ const patterns = [`${entity_type}.${event_type}`, `${entity_type}.*`, "*"];
610
+ for (const pattern of patterns) {
611
+ const matched = this.handlers.get(pattern);
612
+ if (!matched) continue;
613
+ for (const handler of matched) {
614
+ await handler(delivery);
615
+ }
616
+ }
617
+ return delivery;
618
+ }
619
+ };
620
+
296
621
  // src/client.ts
297
- var BlueprintClient = class {
622
+ var PlanesClient = class {
298
623
  constructor(config) {
299
- if (!config.accessToken && !config.apiKey) {
300
- throw new Error("BlueprintClient requires either accessToken or apiKey");
624
+ const hasOAuthCreds = Boolean(config.clientId) || Boolean(config.clientSecret);
625
+ const modes = [
626
+ config.accessToken ? "accessToken" : null,
627
+ config.apiKey ? "apiKey" : null,
628
+ hasOAuthCreds ? "clientId+clientSecret" : null
629
+ ].filter((m) => m !== null);
630
+ if (modes.length === 0) {
631
+ throw new Error(
632
+ "PlanesClient requires one auth mode: accessToken, apiKey, or clientId + clientSecret"
633
+ );
301
634
  }
302
- if (config.accessToken && config.apiKey) {
303
- throw new Error("BlueprintClient requires either accessToken or apiKey, not both");
635
+ if (modes.length > 1) {
636
+ throw new Error(
637
+ `PlanesClient requires exactly one auth mode, not multiple (got: ${modes.join(", ")})`
638
+ );
639
+ }
640
+ if (hasOAuthCreds && (!config.clientId || !config.clientSecret)) {
641
+ throw new Error("PlanesClient OAuth mode requires both clientId and clientSecret");
304
642
  }
305
643
  this.http = new HttpClient(config);
306
644
  }
@@ -358,17 +696,134 @@ var BlueprintClient = class {
358
696
  }
359
697
  return this._inventory;
360
698
  }
699
+ get contacts() {
700
+ if (!this._contacts) {
701
+ this._contacts = new ContactResource(this.http);
702
+ }
703
+ return this._contacts;
704
+ }
705
+ get activities() {
706
+ if (!this._activities) {
707
+ this._activities = new ActivityResource(this.http);
708
+ }
709
+ return this._activities;
710
+ }
711
+ get tags() {
712
+ if (!this._tags) {
713
+ this._tags = new TagResource(this.http);
714
+ }
715
+ return this._tags;
716
+ }
717
+ /**
718
+ * Ergonomic webhook event router — `client.webhooks.on(pattern, handler)`
719
+ * plus `client.webhooks.process({ body, signature, secret })`. Auth-independent
720
+ * (the signing secret is supplied per `.process()` call), so it is created
721
+ * lazily without the HTTP client. See {@link WebhookRouter}.
722
+ */
723
+ get webhooks() {
724
+ if (!this._webhooks) {
725
+ this._webhooks = new WebhookRouter();
726
+ }
727
+ return this._webhooks;
728
+ }
361
729
  };
362
730
 
363
731
  // src/agent.ts
364
732
  import * as http from "http";
365
- var Blueprint = {
733
+ function createActionServer(handlerMap, options) {
734
+ const port = options?.port ?? 3e3;
735
+ const basePath = options?.path ?? "/actions";
736
+ const onError = options?.onError;
737
+ const server = http.createServer(async (req, res) => {
738
+ if (req.method !== "POST" || req.url !== basePath) {
739
+ res.writeHead(404, { "Content-Type": "application/json" });
740
+ res.end(JSON.stringify({ error: "Not found" }));
741
+ return;
742
+ }
743
+ try {
744
+ const body = await readBody(req);
745
+ let envelope;
746
+ try {
747
+ envelope = JSON.parse(body);
748
+ } catch {
749
+ res.writeHead(400, { "Content-Type": "application/json" });
750
+ res.end(JSON.stringify({ error: "Malformed request body" }));
751
+ return;
752
+ }
753
+ const wire = envelope?.action_context;
754
+ const actionType = wire?.action_type;
755
+ if (!wire || !actionType) {
756
+ res.writeHead(400, { "Content-Type": "application/json" });
757
+ res.end(
758
+ JSON.stringify({ error: "Missing action_context.action_type in request body" })
759
+ );
760
+ return;
761
+ }
762
+ const handler = handlerMap.get(actionType);
763
+ if (!handler) {
764
+ res.writeHead(404, { "Content-Type": "application/json" });
765
+ res.end(
766
+ JSON.stringify({
767
+ error: `No handler registered for action type: ${actionType}`
768
+ })
769
+ );
770
+ return;
771
+ }
772
+ const requestIdHeader = req.headers["x-planes-request-id"];
773
+ const requestId = Array.isArray(requestIdHeader) ? requestIdHeader[0] : requestIdHeader;
774
+ const context = {
775
+ action_id: wire.action_id,
776
+ action_type: actionType,
777
+ entity_type: wire.entity_type,
778
+ entity_id: wire.entity_id,
779
+ tenant_id: wire.tenant_id,
780
+ data: wire.data ?? {},
781
+ stage: wire.stage ?? null,
782
+ metadata: wire.metadata,
783
+ callback_token: envelope.callback_token,
784
+ callback_base_url: envelope.callback_base_url,
785
+ // Only set request_id when the header was present (exactOptionalPropertyTypes).
786
+ ...requestId !== void 0 ? { request_id: requestId } : {}
787
+ };
788
+ let result;
789
+ try {
790
+ result = await handler.execute(context);
791
+ } catch (err) {
792
+ const errorMessage = err instanceof Error ? err.message : "Unknown handler error";
793
+ if (onError && err instanceof Error) {
794
+ onError(err);
795
+ }
796
+ result = {
797
+ status: "failed",
798
+ error: errorMessage,
799
+ retryable: false
800
+ };
801
+ }
802
+ res.writeHead(200, { "Content-Type": "application/json" });
803
+ res.end(JSON.stringify(result));
804
+ } catch (err) {
805
+ if (onError && err instanceof Error) {
806
+ onError(err);
807
+ }
808
+ res.writeHead(500, { "Content-Type": "application/json" });
809
+ res.end(JSON.stringify({ error: "Internal server error" }));
810
+ }
811
+ });
812
+ server.listen(port);
813
+ const shutdown = () => {
814
+ server.close();
815
+ };
816
+ process.on("SIGTERM", shutdown);
817
+ process.on("SIGINT", shutdown);
818
+ return server;
819
+ }
820
+ var Planes = {
366
821
  /**
367
822
  * Register action handlers and start an HTTP server to receive action requests.
368
823
  *
369
824
  * @example
370
825
  * ```ts
371
- * Blueprint.register([
826
+ * Planes.register([
372
827
  * {
373
828
  * type: 'send_email',
374
829
  * async execute(ctx) {
@@ -380,77 +835,45 @@ var Blueprint = {
380
835
  * ```
381
836
  */
382
837
  register(handlers, options) {
383
- const port = options?.port ?? 3e3;
384
- const basePath = options?.path ?? "/actions";
385
- const onError = options?.onError;
386
838
  const handlerMap = /* @__PURE__ */ new Map();
387
839
  for (const handler of handlers) {
388
840
  handlerMap.set(handler.type, handler);
389
841
  }
390
- const server = http.createServer(async (req, res) => {
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;
842
+ return createActionServer(handlerMap, options);
452
843
  }
453
844
  };
845
+ var AgentApp = class {
846
+ constructor() {
847
+ this.handlers = /* @__PURE__ */ new Map();
848
+ }
849
+ /**
850
+ * Register a handler for an action type. Accepts a bare `execute` function
851
+ * (the caller does not build the `{ type, execute }` object).
852
+ *
853
+ * @param type - The action type this handler responds to.
854
+ * @param execute - The handler function invoked with the {@link ActionContext}.
855
+ * @returns `this`, for chaining.
856
+ */
857
+ registerAction(type, execute) {
858
+ this.handlers.set(type, { type, execute });
859
+ return this;
860
+ }
861
+ /**
862
+ * Start an HTTP server that dispatches incoming action requests to the
863
+ * registered handlers. Delegates to the same server factory as
864
+ * {@link Planes.register}.
865
+ *
866
+ * The handler set is snapshotted at call time, so registering further actions
867
+ * after `listen()` does not mutate the running server (parity with
868
+ * `Planes.register`, which snapshots its array).
869
+ */
870
+ listen(options) {
871
+ return createActionServer(new Map(this.handlers), options);
872
+ }
873
+ };
874
+ function createAgentApp() {
875
+ return new AgentApp();
876
+ }
454
877
  function readBody(req) {
455
878
  return new Promise((resolve, reject) => {
456
879
  const chunks = [];
@@ -459,120 +882,41 @@ function readBody(req) {
459
882
  req.on("error", reject);
460
883
  });
461
884
  }
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
885
  export {
886
+ ActivityResource,
887
+ AgentApp,
551
888
  AuthenticationError,
552
889
  AuthorizationError,
553
890
  BaseResource,
554
- Blueprint,
555
- BlueprintClient,
556
- BlueprintError,
557
891
  CalendarEventResource,
892
+ ContactChannelResource,
893
+ ContactResource,
558
894
  ContractResource,
559
895
  CustomerResource,
560
896
  DEFAULT_BASE_URL,
561
897
  DEFAULT_MAX_RETRIES,
562
898
  DEFAULT_TIMEOUT,
899
+ DEFAULT_TOKEN_REFRESH_SKEW_MS,
563
900
  EstimateResource,
564
901
  InventoryResource,
565
902
  InvoiceResource,
566
903
  JobResource,
904
+ MemoryTokenStorage,
567
905
  NotFoundError,
568
906
  OAuthError,
569
907
  OpportunityResource,
908
+ Planes,
909
+ PlanesClient,
910
+ PlanesError,
570
911
  PropertyResource,
571
912
  RateLimitError,
572
913
  ServerError,
914
+ TagResource,
573
915
  ValidationError,
916
+ WebhookRouter,
574
917
  WebhookVerificationError,
575
918
  buildAuthorizeUrl,
919
+ createAgentApp,
576
920
  exchangeCode,
577
921
  parseWebhookEvent,
578
922
  refreshAccessToken,