@edge-markets/connect-node 1.6.0 → 1.7.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
@@ -21,6 +21,14 @@ import {
21
21
 
22
22
  // src/validators.ts
23
23
  import { EdgeValidationError } from "@edge-markets/connect";
24
+ var UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
25
+ function validateTransferId(transferId) {
26
+ if (typeof transferId !== "string" || !UUID_V4_REGEX.test(transferId)) {
27
+ throw new EdgeValidationError("transferId must be a UUID v4", {
28
+ transferId: ["Must be a valid UUID v4"]
29
+ });
30
+ }
31
+ }
24
32
  function validateVerifyIdentityOptions(options) {
25
33
  const errors = {};
26
34
  const firstName = typeof options.firstName === "string" ? options.firstName.trim() : "";
@@ -97,19 +105,8 @@ var EdgeUserClient = class {
97
105
  }
98
106
  return this.server._apiRequest("POST", "/transfer", this.accessToken, body);
99
107
  }
100
- async verifyTransfer(transferId, otp, options) {
101
- const body = { otp };
102
- if (options?.sdkGeo) {
103
- body.sdkGeo = options.sdkGeo;
104
- }
105
- return this.server._apiRequest(
106
- "POST",
107
- `/transfer/${encodeURIComponent(transferId)}/verify`,
108
- this.accessToken,
109
- body
110
- );
111
- }
112
108
  async getTransfer(transferId) {
109
+ validateTransferId(transferId);
113
110
  return this.server._apiRequest("GET", `/transfer/${encodeURIComponent(transferId)}`, this.accessToken);
114
111
  }
115
112
  async listTransfers(params) {
@@ -130,6 +127,7 @@ var EdgeUserClient = class {
130
127
  * The handoff token is single-use and expires in 120 seconds.
131
128
  */
132
129
  async createVerificationSession(transferId, options) {
130
+ validateTransferId(transferId);
133
131
  return this.server._apiRequest(
134
132
  "POST",
135
133
  `/transfer/${encodeURIComponent(transferId)}/verification-session`,
@@ -142,6 +140,7 @@ var EdgeUserClient = class {
142
140
  * Use as an alternative to postMessage events.
143
141
  */
144
142
  async getVerificationSessionStatus(transferId, sessionId) {
143
+ validateTransferId(transferId);
145
144
  return this.server._apiRequest(
146
145
  "GET",
147
146
  `/transfer/${encodeURIComponent(transferId)}/verification-session/${encodeURIComponent(sessionId)}`,
@@ -271,19 +270,31 @@ var instances = /* @__PURE__ */ new Map();
271
270
  function getInstanceKey(config) {
272
271
  return `${config.clientId}:${config.environment}`;
273
272
  }
274
- var EdgeConnectServer = class _EdgeConnectServer {
275
- static getInstance(config) {
276
- const key = getInstanceKey(config);
277
- const existing = instances.get(key);
278
- if (existing) return existing;
279
- const instance = new _EdgeConnectServer(config);
280
- instances.set(key, instance);
281
- return instance;
273
+ var MTLS_ENFORCED_HOSTS = /* @__PURE__ */ new Set(["connect-staging.edgeboost.io", "api.edgeboost.com"]);
274
+ function isMtlsEnforcedHost(host) {
275
+ if (MTLS_ENFORCED_HOSTS.has(host)) {
276
+ return true;
277
+ }
278
+ for (const enforced of MTLS_ENFORCED_HOSTS) {
279
+ if (host.endsWith("." + enforced)) {
280
+ return true;
281
+ }
282
282
  }
283
- static clearInstances() {
284
- instances.clear();
283
+ return false;
284
+ }
285
+ function environmentRequiresMtls(environment, apiBaseUrl) {
286
+ if (environment === "staging" || environment === "production") {
287
+ return true;
285
288
  }
289
+ try {
290
+ return isMtlsEnforcedHost(new URL(apiBaseUrl).host);
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
295
+ var EdgeConnectServer = class _EdgeConnectServer {
286
296
  constructor(config) {
297
+ this.partnerTokenCache = null;
287
298
  if (!config.clientId) {
288
299
  throw new Error("EdgeConnectServer: clientId is required");
289
300
  }
@@ -306,8 +317,23 @@ var EdgeConnectServer = class _EdgeConnectServer {
306
317
  validateMtlsConfig(config.mtls);
307
318
  this.httpsAgent = createHttpsAgent(config.mtls);
308
319
  this.dispatcher = createUndiciDispatcher(config.mtls);
320
+ } else if (environmentRequiresMtls(config.environment, this.apiBaseUrl)) {
321
+ console.warn(
322
+ `[EdgeConnectServer] environment='${config.environment}' is served from an mTLS-enforced host (${this.apiBaseUrl}) but no mtls config was provided. Requests will fail at the TLS handshake with a connection reset. Pass mtls: { enabled: true, cert, key, ca? } to EdgeConnectServer or coordinate with EDGE DevRel to issue a partner client certificate.`
323
+ );
309
324
  }
310
325
  }
326
+ static getInstance(config) {
327
+ const key = getInstanceKey(config);
328
+ const existing = instances.get(key);
329
+ if (existing) return existing;
330
+ const instance = new _EdgeConnectServer(config);
331
+ instances.set(key, instance);
332
+ return instance;
333
+ }
334
+ static clearInstances() {
335
+ instances.clear();
336
+ }
311
337
  /**
312
338
  * Returns the `node:https.Agent` configured with mTLS client certificates.
313
339
  * Use this with axios, got, or other HTTP clients. Returns `undefined` if mTLS is not configured.
@@ -478,6 +504,175 @@ var EdgeConnectServer = class _EdgeConnectServer {
478
504
  lastError
479
505
  );
480
506
  }
507
+ /**
508
+ * Reconcile webhook events from the partner sync endpoint.
509
+ *
510
+ * Use this from a scheduled cron (every ~5 minutes is typical) to catch
511
+ * events your primary webhook receiver missed — partner downtime, retry
512
+ * exhaustion, dead-letter queue. This is the third reliability layer
513
+ * ("Leg 3") on top of HTTP delivery + DLQ.
514
+ *
515
+ * **Authentication.** This method uses the OAuth `client_credentials`
516
+ * grant against {@link EdgeConnectServerConfig.partnerClientId} and
517
+ * {@link EdgeConnectServerConfig.partnerClientSecret}. The token is
518
+ * cached internally and refreshed automatically.
519
+ *
520
+ * **Pagination.** Pass `afterEventId` with the last `id` you processed.
521
+ * Loop while `hasMore` is true. The server enforces a 30-day lookback;
522
+ * cursors older than 30 days return events from 30 days ago, not an error.
523
+ *
524
+ * @example
525
+ * ```typescript
526
+ * let cursor: string | undefined = await loadCursorFromDb()
527
+ *
528
+ * while (true) {
529
+ * const { events, hasMore } = await edge.syncWebhookEvents({ afterEventId: cursor })
530
+ * for (const event of events) {
531
+ * await processEvent(event)
532
+ * cursor = event.id
533
+ * }
534
+ * if (events.length > 0) await saveCursorToDb(cursor!)
535
+ * if (!hasMore) break
536
+ * }
537
+ * ```
538
+ *
539
+ * @throws {EdgeAuthenticationError} If partner credentials are missing
540
+ * or the partner token cannot be obtained / refreshed
541
+ * @throws {EdgeNetworkError} On network failure
542
+ * @throws {EdgeApiError} For other non-2xx responses after retries
543
+ */
544
+ async syncWebhookEvents(options = {}) {
545
+ if (!this.config.partnerClientId) {
546
+ throw new EdgeAuthenticationError("syncWebhookEvents requires `partnerClientId` in EdgeConnectServer config");
547
+ }
548
+ if (!this.config.partnerClientSecret) {
549
+ throw new EdgeAuthenticationError("syncWebhookEvents requires `partnerClientSecret` in EdgeConnectServer config");
550
+ }
551
+ const url = this.buildSyncUrl(options);
552
+ let token = await this.getPartnerToken();
553
+ let response = await this.partnerFetch(url, token);
554
+ if (response.status === 401) {
555
+ this.partnerTokenCache = null;
556
+ token = await this.getPartnerToken();
557
+ response = await this.partnerFetch(url, token);
558
+ if (response.status === 401) {
559
+ const body = await response.json().catch(() => ({}));
560
+ throw new EdgeAuthenticationError(
561
+ body.message || "Partner token rejected after refresh \u2014 check partnerClientId/Secret",
562
+ body
563
+ );
564
+ }
565
+ }
566
+ if (!response.ok) {
567
+ const body = await response.json().catch(() => ({}));
568
+ throw new EdgeApiError(
569
+ body.error || "sync_failed",
570
+ body.message || `syncWebhookEvents failed with status ${response.status}`,
571
+ response.status,
572
+ body
573
+ );
574
+ }
575
+ const wire = await response.json().catch(() => ({}));
576
+ const events = (wire.events ?? []).map((entry) => entry.payload).filter((p) => !!p);
577
+ return {
578
+ events,
579
+ hasMore: !!wire.has_more
580
+ };
581
+ }
582
+ /**
583
+ * Builds the sync URL with normalized query parameters.
584
+ */
585
+ buildSyncUrl(options) {
586
+ const url = new URL(`${this.apiBaseUrl}/partner/webhooks/events/sync`);
587
+ if (options.afterEventId) {
588
+ url.searchParams.set("after_event_id", options.afterEventId);
589
+ }
590
+ if (options.limit !== void 0) {
591
+ url.searchParams.set("limit", String(options.limit));
592
+ }
593
+ return url.toString();
594
+ }
595
+ /**
596
+ * Issues a GET to the sync endpoint with the partner Bearer token. Kept
597
+ * separate from `_apiRequest` because partner tokens have a different
598
+ * lifecycle (process-wide, client_credentials) than user tokens.
599
+ */
600
+ async partnerFetch(url, token) {
601
+ try {
602
+ return await this.fetchWithTimeout(
603
+ url,
604
+ {
605
+ method: "GET",
606
+ headers: {
607
+ Authorization: `Bearer ${token}`,
608
+ "Content-Type": "application/json",
609
+ "User-Agent": USER_AGENT
610
+ }
611
+ },
612
+ this.dispatcher
613
+ );
614
+ } catch (error) {
615
+ throw new EdgeNetworkError("syncWebhookEvents network failure", error);
616
+ }
617
+ }
618
+ /**
619
+ * Fetches (or returns the cached) partner token via the
620
+ * `client_credentials` grant. Caches with a 60s safety margin so the
621
+ * token is renewed *before* the server-side expiry, never racing it.
622
+ *
623
+ * Side-effects: writes `this.partnerTokenCache` on success.
624
+ */
625
+ async getPartnerToken() {
626
+ const cached = this.partnerTokenCache;
627
+ if (cached && Date.now() < cached.expiresAt) {
628
+ return cached.token;
629
+ }
630
+ const tokenUrl = `${this.oauthBaseUrl}/token`;
631
+ let response;
632
+ try {
633
+ response = await this.fetchWithTimeout(
634
+ tokenUrl,
635
+ {
636
+ method: "POST",
637
+ headers: {
638
+ "Content-Type": "application/x-www-form-urlencoded",
639
+ "User-Agent": USER_AGENT
640
+ },
641
+ body: new URLSearchParams({
642
+ grant_type: "client_credentials",
643
+ client_id: this.config.partnerClientId,
644
+ client_secret: this.config.partnerClientSecret
645
+ }).toString()
646
+ },
647
+ this.dispatcher
648
+ );
649
+ } catch (error) {
650
+ throw new EdgeNetworkError("Partner token request network failure", error);
651
+ }
652
+ if (!response.ok) {
653
+ const body = await response.json().catch(() => ({}));
654
+ throw new EdgeAuthenticationError(
655
+ body.message || body.error_description || `Partner token request failed with status ${response.status}`,
656
+ body
657
+ );
658
+ }
659
+ const data = await response.json().catch(() => ({}));
660
+ if (!data.access_token || typeof data.expires_in !== "number") {
661
+ throw new EdgeAuthenticationError("Partner token response missing access_token or expires_in");
662
+ }
663
+ this.partnerTokenCache = {
664
+ token: data.access_token,
665
+ expiresAt: Date.now() + (data.expires_in - 60) * 1e3
666
+ };
667
+ return data.access_token;
668
+ }
669
+ /**
670
+ * @internal Test-only: clears the cached partner token so the next call
671
+ * to `syncWebhookEvents` re-fetches it. Not part of the public API.
672
+ */
673
+ _resetPartnerTokenCache() {
674
+ this.partnerTokenCache = null;
675
+ }
481
676
  getRetryDelay(attempt) {
482
677
  const { backoff, baseDelayMs } = this.retryConfig;
483
678
  if (backoff === "exponential") {
@@ -587,6 +782,35 @@ var EdgeConnectServer = class _EdgeConnectServer {
587
782
  }
588
783
  };
589
784
 
785
+ // src/webhook-signing.ts
786
+ import crypto from "crypto";
787
+ function verifyWebhookSignature(header, body, secret, options = {}) {
788
+ if (typeof header !== "string" || typeof body !== "string" || typeof secret !== "string") {
789
+ return false;
790
+ }
791
+ const requestedTolerance = options.toleranceSeconds;
792
+ const toleranceSeconds = typeof requestedTolerance === "number" && Number.isFinite(requestedTolerance) && requestedTolerance >= 0 ? requestedTolerance : 300;
793
+ const parts = header.split(",").map((p) => p.trim());
794
+ const tPart = parts.find((p) => p.startsWith("t="));
795
+ const v1Part = parts.find((p) => p.startsWith("v1="));
796
+ if (!tPart || !v1Part) return false;
797
+ const timestamp = parseInt(tPart.slice(2), 10);
798
+ const signature = v1Part.slice(3);
799
+ if (isNaN(timestamp) || !signature) return false;
800
+ const now = Math.floor(Date.now() / 1e3);
801
+ if (Math.abs(now - timestamp) > toleranceSeconds) return false;
802
+ const signedContent = `${timestamp}.${body}`;
803
+ const expectedHmac = crypto.createHmac("sha256", secret).update(signedContent).digest("hex");
804
+ try {
805
+ const sigBuf = Buffer.from(signature, "hex");
806
+ const expBuf = Buffer.from(expectedHmac, "hex");
807
+ if (sigBuf.length !== expBuf.length) return false;
808
+ return crypto.timingSafeEqual(sigBuf, expBuf);
809
+ } catch {
810
+ return false;
811
+ }
812
+ }
813
+
590
814
  // src/index.ts
591
815
  import {
592
816
  EdgeApiError as EdgeApiError2,
@@ -605,8 +829,14 @@ import {
605
829
  isIdentityVerificationError,
606
830
  isNetworkError
607
831
  } from "@edge-markets/connect";
608
- import { TRANSFER_CATEGORIES, getEnvironmentConfig as getEnvironmentConfig2, isProductionEnvironment } from "@edge-markets/connect";
832
+ import {
833
+ EDGE_WEBHOOK_EVENT_TYPES,
834
+ TRANSFER_CATEGORIES,
835
+ getEnvironmentConfig as getEnvironmentConfig2,
836
+ isProductionEnvironment
837
+ } from "@edge-markets/connect";
609
838
  export {
839
+ EDGE_WEBHOOK_EVENT_TYPES,
610
840
  EdgeApiError2 as EdgeApiError,
611
841
  EdgeAuthenticationError2 as EdgeAuthenticationError,
612
842
  EdgeConnectServer,
@@ -626,5 +856,6 @@ export {
626
856
  isEdgeError,
627
857
  isIdentityVerificationError,
628
858
  isNetworkError,
629
- isProductionEnvironment
859
+ isProductionEnvironment,
860
+ verifyWebhookSignature
630
861
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edge-markets/connect-node",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Server SDK for EDGE Connect token exchange and API calls",
5
5
  "author": "Edge Markets",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  }
22
22
  },
23
23
  "dependencies": {
24
- "@edge-markets/connect": "^1.5.0"
24
+ "@edge-markets/connect": "^1.6.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "tsup": "^8.0.0",