@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/README.md +196 -8
- package/dist/index.d.mts +179 -11
- package/dist/index.d.ts +179 -11
- package/dist/index.js +252 -24
- package/dist/index.mjs +255 -24
- package/package.json +2 -2
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
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
24
|
+
"@edge-markets/connect": "^1.6.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"tsup": "^8.0.0",
|