@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.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as node_https from 'node:https';
|
|
2
|
-
import { EdgeEnvironment, TransferCategory,
|
|
3
|
-
export { Balance, CreateVerificationSessionRequest, EdgeApiError, EdgeAuthenticationError, EdgeConsentRequiredError, EdgeEnvironment, EdgeError, EdgeIdentityVerificationError, EdgeInsufficientScopeError, EdgeNetworkError, EdgeNotFoundError, EdgeTokenExchangeError, EdgeTokens, ListTransfersParams, SdkGeolocation, TRANSFER_CATEGORIES, Transfer, TransferCategory, TransferList, TransferListItem, TransferStatus, TransferType, User, UserAddress, VerificationSession, VerificationSessionStatus, VerificationSessionStatusResponse, VerifyIdentityAddress, VerifyIdentityOptions, VerifyIdentityResult, VerifyIdentityScores, getEnvironmentConfig, isApiError, isAuthenticationError, isConsentRequiredError, isEdgeError, isIdentityVerificationError, isNetworkError, isProductionEnvironment } from '@edge-markets/connect';
|
|
2
|
+
import { EdgeEnvironment, TransferCategory, User, VerifyIdentityOptions, VerifyIdentityResult, Balance, Transfer, ListTransfersParams, TransferList, CreateVerificationSessionRequest, VerificationSession, VerificationSessionStatusResponse, EdgeTokens, EdgeWebhookEvent } from '@edge-markets/connect';
|
|
3
|
+
export { Balance, BaseWebhookEvent, ConsentRevokedEventData, CreateVerificationSessionRequest, EDGE_WEBHOOK_EVENT_TYPES, EdgeApiError, EdgeAuthenticationError, EdgeConsentRequiredError, EdgeEnvironment, EdgeError, EdgeIdentityVerificationError, EdgeInsufficientScopeError, EdgeNetworkError, EdgeNotFoundError, EdgeTokenExchangeError, EdgeTokens, EdgeWebhookEvent, EdgeWebhookEventType, ListTransfersParams, SdkGeolocation, TRANSFER_CATEGORIES, Transfer, TransferCategory, TransferCompletedEventData, TransferExpiredEventData, TransferFailedEventData, TransferList, TransferListItem, TransferProcessingEventData, TransferStatus, TransferType, User, UserAddress, VerificationSession, VerificationSessionStatus, VerificationSessionStatusResponse, VerifyIdentityAddress, VerifyIdentityOptions, VerifyIdentityResult, VerifyIdentityScores, getEnvironmentConfig, isApiError, isAuthenticationError, isConsentRequiredError, isEdgeError, isIdentityVerificationError, isNetworkError, isProductionEnvironment } from '@edge-markets/connect';
|
|
4
4
|
|
|
5
5
|
interface RequestInfo {
|
|
6
6
|
method: string;
|
|
@@ -42,6 +42,24 @@ interface EdgeConnectServerConfig {
|
|
|
42
42
|
strictResponseEncryption?: boolean;
|
|
43
43
|
};
|
|
44
44
|
mtls?: MtlsConfig;
|
|
45
|
+
/**
|
|
46
|
+
* Partner-level OAuth client ID for the `client_credentials` grant.
|
|
47
|
+
*
|
|
48
|
+
* Required ONLY when calling {@link EdgeConnectServer.syncWebhookEvents}.
|
|
49
|
+
* This is a different OAuth client than {@link clientId} — it authenticates
|
|
50
|
+
* your backend (not your users) for partner-scoped surfaces like the
|
|
51
|
+
* webhook reconciliation sync endpoint.
|
|
52
|
+
*
|
|
53
|
+
* If omitted, `syncWebhookEvents()` throws on first call but the rest of
|
|
54
|
+
* the SDK (token exchange, user-scoped API calls) continues to work.
|
|
55
|
+
*/
|
|
56
|
+
partnerClientId?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Partner-level OAuth client secret paired with {@link partnerClientId}.
|
|
59
|
+
*
|
|
60
|
+
* Required ONLY when calling {@link EdgeConnectServer.syncWebhookEvents}.
|
|
61
|
+
*/
|
|
62
|
+
partnerClientSecret?: string;
|
|
45
63
|
}
|
|
46
64
|
interface MtlsConfig {
|
|
47
65
|
enabled: boolean;
|
|
@@ -56,13 +74,6 @@ interface TransferOptions {
|
|
|
56
74
|
/** Optional transaction category for settlement window determination */
|
|
57
75
|
category?: TransferCategory;
|
|
58
76
|
}
|
|
59
|
-
/**
|
|
60
|
-
* Options for verifying a transfer with OTP.
|
|
61
|
-
*/
|
|
62
|
-
interface VerifyTransferOptions {
|
|
63
|
-
/** SDK-reported geolocation for cross-referencing against IP-based geo */
|
|
64
|
-
sdkGeo?: SdkGeolocation;
|
|
65
|
-
}
|
|
66
77
|
|
|
67
78
|
/**
|
|
68
79
|
* A lightweight, user-scoped API client created via {@link EdgeConnectServer.forUser}.
|
|
@@ -78,7 +89,6 @@ declare class EdgeUserClient {
|
|
|
78
89
|
verifyIdentity(options: VerifyIdentityOptions): Promise<VerifyIdentityResult>;
|
|
79
90
|
getBalance(): Promise<Balance>;
|
|
80
91
|
initiateTransfer(options: TransferOptions): Promise<Transfer>;
|
|
81
|
-
verifyTransfer(transferId: string, otp: string, options?: VerifyTransferOptions): Promise<Transfer>;
|
|
82
92
|
getTransfer(transferId: string): Promise<Transfer>;
|
|
83
93
|
listTransfers(params?: ListTransfersParams): Promise<TransferList>;
|
|
84
94
|
revokeConsent(): Promise<{
|
|
@@ -97,6 +107,38 @@ declare class EdgeUserClient {
|
|
|
97
107
|
getVerificationSessionStatus(transferId: string, sessionId: string): Promise<VerificationSessionStatusResponse>;
|
|
98
108
|
}
|
|
99
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Options for {@link EdgeConnectServer.syncWebhookEvents}.
|
|
112
|
+
*/
|
|
113
|
+
interface SyncWebhookEventsOptions {
|
|
114
|
+
/**
|
|
115
|
+
* Cursor token. Pass the `id` of the last event you successfully processed
|
|
116
|
+
* to receive only newer events. Omit on the first call to start from the
|
|
117
|
+
* beginning of the 30-day server-side lookback window.
|
|
118
|
+
*/
|
|
119
|
+
afterEventId?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Maximum events per page (1–100). Defaults to 20 server-side.
|
|
122
|
+
*/
|
|
123
|
+
limit?: number;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Result of {@link EdgeConnectServer.syncWebhookEvents}. Field names are
|
|
127
|
+
* normalized to camelCase from the wire format (`has_more` → `hasMore`).
|
|
128
|
+
*/
|
|
129
|
+
interface SyncWebhookEventsResult {
|
|
130
|
+
/**
|
|
131
|
+
* Events newer than the supplied cursor, in ascending order. Each event
|
|
132
|
+
* is the unwrapped {@link EdgeWebhookEvent} payload (the SDK strips the
|
|
133
|
+
* sync API's outer envelope).
|
|
134
|
+
*/
|
|
135
|
+
events: EdgeWebhookEvent[];
|
|
136
|
+
/**
|
|
137
|
+
* `true` if more events are available beyond this page. Loop while this
|
|
138
|
+
* is truthy, advancing your cursor to the `id` of the last processed event.
|
|
139
|
+
*/
|
|
140
|
+
hasMore: boolean;
|
|
141
|
+
}
|
|
100
142
|
declare class EdgeConnectServer {
|
|
101
143
|
private readonly config;
|
|
102
144
|
private readonly apiBaseUrl;
|
|
@@ -105,6 +147,7 @@ declare class EdgeConnectServer {
|
|
|
105
147
|
private readonly retryConfig;
|
|
106
148
|
private readonly httpsAgent;
|
|
107
149
|
private readonly dispatcher;
|
|
150
|
+
private partnerTokenCache;
|
|
108
151
|
static getInstance(config: EdgeConnectServerConfig): EdgeConnectServer;
|
|
109
152
|
static clearInstances(): void;
|
|
110
153
|
constructor(config: EdgeConnectServerConfig);
|
|
@@ -124,6 +167,67 @@ declare class EdgeConnectServer {
|
|
|
124
167
|
refreshTokens(refreshToken: string): Promise<EdgeTokens>;
|
|
125
168
|
/** @internal Called by {@link EdgeUserClient} — not part of the public API. */
|
|
126
169
|
_apiRequest<T>(method: string, path: string, accessToken: string, body?: unknown): Promise<T>;
|
|
170
|
+
/**
|
|
171
|
+
* Reconcile webhook events from the partner sync endpoint.
|
|
172
|
+
*
|
|
173
|
+
* Use this from a scheduled cron (every ~5 minutes is typical) to catch
|
|
174
|
+
* events your primary webhook receiver missed — partner downtime, retry
|
|
175
|
+
* exhaustion, dead-letter queue. This is the third reliability layer
|
|
176
|
+
* ("Leg 3") on top of HTTP delivery + DLQ.
|
|
177
|
+
*
|
|
178
|
+
* **Authentication.** This method uses the OAuth `client_credentials`
|
|
179
|
+
* grant against {@link EdgeConnectServerConfig.partnerClientId} and
|
|
180
|
+
* {@link EdgeConnectServerConfig.partnerClientSecret}. The token is
|
|
181
|
+
* cached internally and refreshed automatically.
|
|
182
|
+
*
|
|
183
|
+
* **Pagination.** Pass `afterEventId` with the last `id` you processed.
|
|
184
|
+
* Loop while `hasMore` is true. The server enforces a 30-day lookback;
|
|
185
|
+
* cursors older than 30 days return events from 30 days ago, not an error.
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* ```typescript
|
|
189
|
+
* let cursor: string | undefined = await loadCursorFromDb()
|
|
190
|
+
*
|
|
191
|
+
* while (true) {
|
|
192
|
+
* const { events, hasMore } = await edge.syncWebhookEvents({ afterEventId: cursor })
|
|
193
|
+
* for (const event of events) {
|
|
194
|
+
* await processEvent(event)
|
|
195
|
+
* cursor = event.id
|
|
196
|
+
* }
|
|
197
|
+
* if (events.length > 0) await saveCursorToDb(cursor!)
|
|
198
|
+
* if (!hasMore) break
|
|
199
|
+
* }
|
|
200
|
+
* ```
|
|
201
|
+
*
|
|
202
|
+
* @throws {EdgeAuthenticationError} If partner credentials are missing
|
|
203
|
+
* or the partner token cannot be obtained / refreshed
|
|
204
|
+
* @throws {EdgeNetworkError} On network failure
|
|
205
|
+
* @throws {EdgeApiError} For other non-2xx responses after retries
|
|
206
|
+
*/
|
|
207
|
+
syncWebhookEvents(options?: SyncWebhookEventsOptions): Promise<SyncWebhookEventsResult>;
|
|
208
|
+
/**
|
|
209
|
+
* Builds the sync URL with normalized query parameters.
|
|
210
|
+
*/
|
|
211
|
+
private buildSyncUrl;
|
|
212
|
+
/**
|
|
213
|
+
* Issues a GET to the sync endpoint with the partner Bearer token. Kept
|
|
214
|
+
* separate from `_apiRequest` because partner tokens have a different
|
|
215
|
+
* lifecycle (process-wide, client_credentials) than user tokens.
|
|
216
|
+
*/
|
|
217
|
+
private partnerFetch;
|
|
218
|
+
/**
|
|
219
|
+
* Fetches (or returns the cached) partner token via the
|
|
220
|
+
* `client_credentials` grant. Caches with a 60s safety margin so the
|
|
221
|
+
* token is renewed *before* the server-side expiry, never racing it.
|
|
222
|
+
*
|
|
223
|
+
* Side-effects: writes `this.partnerTokenCache` on success.
|
|
224
|
+
*/
|
|
225
|
+
private getPartnerToken;
|
|
226
|
+
/**
|
|
227
|
+
* @internal Test-only: clears the cached partner token so the next call
|
|
228
|
+
* to `syncWebhookEvents` re-fetches it. Not part of the public API.
|
|
229
|
+
*/
|
|
230
|
+
_resetPartnerTokenCache(): void;
|
|
127
231
|
private getRetryDelay;
|
|
128
232
|
private sleep;
|
|
129
233
|
private fetchWithTimeout;
|
|
@@ -132,4 +236,68 @@ declare class EdgeConnectServer {
|
|
|
132
236
|
private handleApiErrorFromBody;
|
|
133
237
|
}
|
|
134
238
|
|
|
135
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Webhook Signature Verification (SDK-001)
|
|
241
|
+
*
|
|
242
|
+
* HMAC-SHA256 verification for EDGE Connect webhook payloads.
|
|
243
|
+
*
|
|
244
|
+
* Signature header format: `t={timestamp},v1={hex_hmac}`
|
|
245
|
+
* Signed message: `{timestamp}.{raw_json_body}`
|
|
246
|
+
*
|
|
247
|
+
* The timestamp prefix prevents replay attacks — events older than the
|
|
248
|
+
* tolerance window (default 5 minutes) are rejected. The HMAC is verified
|
|
249
|
+
* with constant-time comparison to prevent timing attacks.
|
|
250
|
+
*/
|
|
251
|
+
/**
|
|
252
|
+
* Options for {@link verifyWebhookSignature}.
|
|
253
|
+
*/
|
|
254
|
+
interface VerifyWebhookSignatureOptions {
|
|
255
|
+
/**
|
|
256
|
+
* Maximum age of the signature timestamp, in seconds.
|
|
257
|
+
*
|
|
258
|
+
* Signatures older than this are rejected as potential replays.
|
|
259
|
+
* Defaults to 300 seconds (5 minutes), matching the producer.
|
|
260
|
+
*/
|
|
261
|
+
toleranceSeconds?: number;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Verifies an EDGE Connect webhook signature.
|
|
265
|
+
*
|
|
266
|
+
* **Always pass the raw request body bytes** — never `JSON.stringify(req.body)`.
|
|
267
|
+
* Re-stringifying after the framework parses JSON will reorder keys, change
|
|
268
|
+
* whitespace, and silently break verification with no diagnostic. Capture
|
|
269
|
+
* the raw body via your framework's raw-body middleware:
|
|
270
|
+
*
|
|
271
|
+
* - Express: `express.raw({ type: 'application/json' })`
|
|
272
|
+
* - Fastify: built-in (use `request.rawBody`)
|
|
273
|
+
* - NestJS: `{ rawBody: true }` on `NestFactory.create` + `RawBodyRequest<Request>`
|
|
274
|
+
*
|
|
275
|
+
* @param header - The `X-Edge-Signature` header value (`t=...,v1=...`)
|
|
276
|
+
* @param body - The raw request body as a UTF-8 string (NOT a parsed object)
|
|
277
|
+
* @param secret - The partner's webhook signing secret
|
|
278
|
+
* @param options - Optional overrides
|
|
279
|
+
* @returns `true` if the signature is valid and within the tolerance window,
|
|
280
|
+
* `false` otherwise. Never throws — malformed input returns `false`.
|
|
281
|
+
*
|
|
282
|
+
* @example
|
|
283
|
+
* ```typescript
|
|
284
|
+
* import { verifyWebhookSignature } from '@edge-markets/connect-node'
|
|
285
|
+
*
|
|
286
|
+
* app.post('/webhooks/edge', express.raw({ type: 'application/json' }), (req, res) => {
|
|
287
|
+
* const ok = verifyWebhookSignature(
|
|
288
|
+
* req.headers['x-edge-signature'] as string,
|
|
289
|
+
* req.body.toString('utf8'),
|
|
290
|
+
* process.env.EDGE_WEBHOOK_SECRET!,
|
|
291
|
+
* )
|
|
292
|
+
*
|
|
293
|
+
* if (!ok) return res.status(401).send('invalid signature')
|
|
294
|
+
*
|
|
295
|
+
* const event = JSON.parse(req.body.toString('utf8'))
|
|
296
|
+
* // ... process event
|
|
297
|
+
* res.status(200).send('ok')
|
|
298
|
+
* })
|
|
299
|
+
* ```
|
|
300
|
+
*/
|
|
301
|
+
declare function verifyWebhookSignature(header: string, body: string, secret: string, options?: VerifyWebhookSignatureOptions): boolean;
|
|
302
|
+
|
|
303
|
+
export { EdgeConnectServer, type EdgeConnectServerConfig, EdgeUserClient, type MtlsConfig, type RequestInfo, type ResponseInfo, type RetryConfig, type SyncWebhookEventsOptions, type SyncWebhookEventsResult, type TransferOptions, type VerifyWebhookSignatureOptions, verifyWebhookSignature };
|
package/dist/index.js
CHANGED
|
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.ts
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
|
+
EDGE_WEBHOOK_EVENT_TYPES: () => import_connect4.EDGE_WEBHOOK_EVENT_TYPES,
|
|
33
34
|
EdgeApiError: () => import_connect3.EdgeApiError,
|
|
34
35
|
EdgeAuthenticationError: () => import_connect3.EdgeAuthenticationError,
|
|
35
36
|
EdgeConnectServer: () => EdgeConnectServer,
|
|
@@ -49,7 +50,8 @@ __export(index_exports, {
|
|
|
49
50
|
isEdgeError: () => import_connect3.isEdgeError,
|
|
50
51
|
isIdentityVerificationError: () => import_connect3.isIdentityVerificationError,
|
|
51
52
|
isNetworkError: () => import_connect3.isNetworkError,
|
|
52
|
-
isProductionEnvironment: () => import_connect4.isProductionEnvironment
|
|
53
|
+
isProductionEnvironment: () => import_connect4.isProductionEnvironment,
|
|
54
|
+
verifyWebhookSignature: () => verifyWebhookSignature
|
|
53
55
|
});
|
|
54
56
|
module.exports = __toCommonJS(index_exports);
|
|
55
57
|
|
|
@@ -58,6 +60,14 @@ var import_connect2 = require("@edge-markets/connect");
|
|
|
58
60
|
|
|
59
61
|
// src/validators.ts
|
|
60
62
|
var import_connect = require("@edge-markets/connect");
|
|
63
|
+
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;
|
|
64
|
+
function validateTransferId(transferId) {
|
|
65
|
+
if (typeof transferId !== "string" || !UUID_V4_REGEX.test(transferId)) {
|
|
66
|
+
throw new import_connect.EdgeValidationError("transferId must be a UUID v4", {
|
|
67
|
+
transferId: ["Must be a valid UUID v4"]
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
61
71
|
function validateVerifyIdentityOptions(options) {
|
|
62
72
|
const errors = {};
|
|
63
73
|
const firstName = typeof options.firstName === "string" ? options.firstName.trim() : "";
|
|
@@ -134,19 +144,8 @@ var EdgeUserClient = class {
|
|
|
134
144
|
}
|
|
135
145
|
return this.server._apiRequest("POST", "/transfer", this.accessToken, body);
|
|
136
146
|
}
|
|
137
|
-
async verifyTransfer(transferId, otp, options) {
|
|
138
|
-
const body = { otp };
|
|
139
|
-
if (options?.sdkGeo) {
|
|
140
|
-
body.sdkGeo = options.sdkGeo;
|
|
141
|
-
}
|
|
142
|
-
return this.server._apiRequest(
|
|
143
|
-
"POST",
|
|
144
|
-
`/transfer/${encodeURIComponent(transferId)}/verify`,
|
|
145
|
-
this.accessToken,
|
|
146
|
-
body
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
147
|
async getTransfer(transferId) {
|
|
148
|
+
validateTransferId(transferId);
|
|
150
149
|
return this.server._apiRequest("GET", `/transfer/${encodeURIComponent(transferId)}`, this.accessToken);
|
|
151
150
|
}
|
|
152
151
|
async listTransfers(params) {
|
|
@@ -167,6 +166,7 @@ var EdgeUserClient = class {
|
|
|
167
166
|
* The handoff token is single-use and expires in 120 seconds.
|
|
168
167
|
*/
|
|
169
168
|
async createVerificationSession(transferId, options) {
|
|
169
|
+
validateTransferId(transferId);
|
|
170
170
|
return this.server._apiRequest(
|
|
171
171
|
"POST",
|
|
172
172
|
`/transfer/${encodeURIComponent(transferId)}/verification-session`,
|
|
@@ -179,6 +179,7 @@ var EdgeUserClient = class {
|
|
|
179
179
|
* Use as an alternative to postMessage events.
|
|
180
180
|
*/
|
|
181
181
|
async getVerificationSessionStatus(transferId, sessionId) {
|
|
182
|
+
validateTransferId(transferId);
|
|
182
183
|
return this.server._apiRequest(
|
|
183
184
|
"GET",
|
|
184
185
|
`/transfer/${encodeURIComponent(transferId)}/verification-session/${encodeURIComponent(sessionId)}`,
|
|
@@ -308,19 +309,31 @@ var instances = /* @__PURE__ */ new Map();
|
|
|
308
309
|
function getInstanceKey(config) {
|
|
309
310
|
return `${config.clientId}:${config.environment}`;
|
|
310
311
|
}
|
|
311
|
-
var
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
312
|
+
var MTLS_ENFORCED_HOSTS = /* @__PURE__ */ new Set(["connect-staging.edgeboost.io", "api.edgeboost.com"]);
|
|
313
|
+
function isMtlsEnforcedHost(host) {
|
|
314
|
+
if (MTLS_ENFORCED_HOSTS.has(host)) {
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
for (const enforced of MTLS_ENFORCED_HOSTS) {
|
|
318
|
+
if (host.endsWith("." + enforced)) {
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
319
321
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
function environmentRequiresMtls(environment, apiBaseUrl) {
|
|
325
|
+
if (environment === "staging" || environment === "production") {
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
return isMtlsEnforcedHost(new URL(apiBaseUrl).host);
|
|
330
|
+
} catch {
|
|
331
|
+
return false;
|
|
322
332
|
}
|
|
333
|
+
}
|
|
334
|
+
var EdgeConnectServer = class _EdgeConnectServer {
|
|
323
335
|
constructor(config) {
|
|
336
|
+
this.partnerTokenCache = null;
|
|
324
337
|
if (!config.clientId) {
|
|
325
338
|
throw new Error("EdgeConnectServer: clientId is required");
|
|
326
339
|
}
|
|
@@ -343,8 +356,23 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
343
356
|
validateMtlsConfig(config.mtls);
|
|
344
357
|
this.httpsAgent = createHttpsAgent(config.mtls);
|
|
345
358
|
this.dispatcher = createUndiciDispatcher(config.mtls);
|
|
359
|
+
} else if (environmentRequiresMtls(config.environment, this.apiBaseUrl)) {
|
|
360
|
+
console.warn(
|
|
361
|
+
`[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.`
|
|
362
|
+
);
|
|
346
363
|
}
|
|
347
364
|
}
|
|
365
|
+
static getInstance(config) {
|
|
366
|
+
const key = getInstanceKey(config);
|
|
367
|
+
const existing = instances.get(key);
|
|
368
|
+
if (existing) return existing;
|
|
369
|
+
const instance = new _EdgeConnectServer(config);
|
|
370
|
+
instances.set(key, instance);
|
|
371
|
+
return instance;
|
|
372
|
+
}
|
|
373
|
+
static clearInstances() {
|
|
374
|
+
instances.clear();
|
|
375
|
+
}
|
|
348
376
|
/**
|
|
349
377
|
* Returns the `node:https.Agent` configured with mTLS client certificates.
|
|
350
378
|
* Use this with axios, got, or other HTTP clients. Returns `undefined` if mTLS is not configured.
|
|
@@ -515,6 +543,175 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
515
543
|
lastError
|
|
516
544
|
);
|
|
517
545
|
}
|
|
546
|
+
/**
|
|
547
|
+
* Reconcile webhook events from the partner sync endpoint.
|
|
548
|
+
*
|
|
549
|
+
* Use this from a scheduled cron (every ~5 minutes is typical) to catch
|
|
550
|
+
* events your primary webhook receiver missed — partner downtime, retry
|
|
551
|
+
* exhaustion, dead-letter queue. This is the third reliability layer
|
|
552
|
+
* ("Leg 3") on top of HTTP delivery + DLQ.
|
|
553
|
+
*
|
|
554
|
+
* **Authentication.** This method uses the OAuth `client_credentials`
|
|
555
|
+
* grant against {@link EdgeConnectServerConfig.partnerClientId} and
|
|
556
|
+
* {@link EdgeConnectServerConfig.partnerClientSecret}. The token is
|
|
557
|
+
* cached internally and refreshed automatically.
|
|
558
|
+
*
|
|
559
|
+
* **Pagination.** Pass `afterEventId` with the last `id` you processed.
|
|
560
|
+
* Loop while `hasMore` is true. The server enforces a 30-day lookback;
|
|
561
|
+
* cursors older than 30 days return events from 30 days ago, not an error.
|
|
562
|
+
*
|
|
563
|
+
* @example
|
|
564
|
+
* ```typescript
|
|
565
|
+
* let cursor: string | undefined = await loadCursorFromDb()
|
|
566
|
+
*
|
|
567
|
+
* while (true) {
|
|
568
|
+
* const { events, hasMore } = await edge.syncWebhookEvents({ afterEventId: cursor })
|
|
569
|
+
* for (const event of events) {
|
|
570
|
+
* await processEvent(event)
|
|
571
|
+
* cursor = event.id
|
|
572
|
+
* }
|
|
573
|
+
* if (events.length > 0) await saveCursorToDb(cursor!)
|
|
574
|
+
* if (!hasMore) break
|
|
575
|
+
* }
|
|
576
|
+
* ```
|
|
577
|
+
*
|
|
578
|
+
* @throws {EdgeAuthenticationError} If partner credentials are missing
|
|
579
|
+
* or the partner token cannot be obtained / refreshed
|
|
580
|
+
* @throws {EdgeNetworkError} On network failure
|
|
581
|
+
* @throws {EdgeApiError} For other non-2xx responses after retries
|
|
582
|
+
*/
|
|
583
|
+
async syncWebhookEvents(options = {}) {
|
|
584
|
+
if (!this.config.partnerClientId) {
|
|
585
|
+
throw new import_connect2.EdgeAuthenticationError("syncWebhookEvents requires `partnerClientId` in EdgeConnectServer config");
|
|
586
|
+
}
|
|
587
|
+
if (!this.config.partnerClientSecret) {
|
|
588
|
+
throw new import_connect2.EdgeAuthenticationError("syncWebhookEvents requires `partnerClientSecret` in EdgeConnectServer config");
|
|
589
|
+
}
|
|
590
|
+
const url = this.buildSyncUrl(options);
|
|
591
|
+
let token = await this.getPartnerToken();
|
|
592
|
+
let response = await this.partnerFetch(url, token);
|
|
593
|
+
if (response.status === 401) {
|
|
594
|
+
this.partnerTokenCache = null;
|
|
595
|
+
token = await this.getPartnerToken();
|
|
596
|
+
response = await this.partnerFetch(url, token);
|
|
597
|
+
if (response.status === 401) {
|
|
598
|
+
const body = await response.json().catch(() => ({}));
|
|
599
|
+
throw new import_connect2.EdgeAuthenticationError(
|
|
600
|
+
body.message || "Partner token rejected after refresh \u2014 check partnerClientId/Secret",
|
|
601
|
+
body
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (!response.ok) {
|
|
606
|
+
const body = await response.json().catch(() => ({}));
|
|
607
|
+
throw new import_connect2.EdgeApiError(
|
|
608
|
+
body.error || "sync_failed",
|
|
609
|
+
body.message || `syncWebhookEvents failed with status ${response.status}`,
|
|
610
|
+
response.status,
|
|
611
|
+
body
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
const wire = await response.json().catch(() => ({}));
|
|
615
|
+
const events = (wire.events ?? []).map((entry) => entry.payload).filter((p) => !!p);
|
|
616
|
+
return {
|
|
617
|
+
events,
|
|
618
|
+
hasMore: !!wire.has_more
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Builds the sync URL with normalized query parameters.
|
|
623
|
+
*/
|
|
624
|
+
buildSyncUrl(options) {
|
|
625
|
+
const url = new URL(`${this.apiBaseUrl}/partner/webhooks/events/sync`);
|
|
626
|
+
if (options.afterEventId) {
|
|
627
|
+
url.searchParams.set("after_event_id", options.afterEventId);
|
|
628
|
+
}
|
|
629
|
+
if (options.limit !== void 0) {
|
|
630
|
+
url.searchParams.set("limit", String(options.limit));
|
|
631
|
+
}
|
|
632
|
+
return url.toString();
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Issues a GET to the sync endpoint with the partner Bearer token. Kept
|
|
636
|
+
* separate from `_apiRequest` because partner tokens have a different
|
|
637
|
+
* lifecycle (process-wide, client_credentials) than user tokens.
|
|
638
|
+
*/
|
|
639
|
+
async partnerFetch(url, token) {
|
|
640
|
+
try {
|
|
641
|
+
return await this.fetchWithTimeout(
|
|
642
|
+
url,
|
|
643
|
+
{
|
|
644
|
+
method: "GET",
|
|
645
|
+
headers: {
|
|
646
|
+
Authorization: `Bearer ${token}`,
|
|
647
|
+
"Content-Type": "application/json",
|
|
648
|
+
"User-Agent": USER_AGENT
|
|
649
|
+
}
|
|
650
|
+
},
|
|
651
|
+
this.dispatcher
|
|
652
|
+
);
|
|
653
|
+
} catch (error) {
|
|
654
|
+
throw new import_connect2.EdgeNetworkError("syncWebhookEvents network failure", error);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Fetches (or returns the cached) partner token via the
|
|
659
|
+
* `client_credentials` grant. Caches with a 60s safety margin so the
|
|
660
|
+
* token is renewed *before* the server-side expiry, never racing it.
|
|
661
|
+
*
|
|
662
|
+
* Side-effects: writes `this.partnerTokenCache` on success.
|
|
663
|
+
*/
|
|
664
|
+
async getPartnerToken() {
|
|
665
|
+
const cached = this.partnerTokenCache;
|
|
666
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
667
|
+
return cached.token;
|
|
668
|
+
}
|
|
669
|
+
const tokenUrl = `${this.oauthBaseUrl}/token`;
|
|
670
|
+
let response;
|
|
671
|
+
try {
|
|
672
|
+
response = await this.fetchWithTimeout(
|
|
673
|
+
tokenUrl,
|
|
674
|
+
{
|
|
675
|
+
method: "POST",
|
|
676
|
+
headers: {
|
|
677
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
678
|
+
"User-Agent": USER_AGENT
|
|
679
|
+
},
|
|
680
|
+
body: new URLSearchParams({
|
|
681
|
+
grant_type: "client_credentials",
|
|
682
|
+
client_id: this.config.partnerClientId,
|
|
683
|
+
client_secret: this.config.partnerClientSecret
|
|
684
|
+
}).toString()
|
|
685
|
+
},
|
|
686
|
+
this.dispatcher
|
|
687
|
+
);
|
|
688
|
+
} catch (error) {
|
|
689
|
+
throw new import_connect2.EdgeNetworkError("Partner token request network failure", error);
|
|
690
|
+
}
|
|
691
|
+
if (!response.ok) {
|
|
692
|
+
const body = await response.json().catch(() => ({}));
|
|
693
|
+
throw new import_connect2.EdgeAuthenticationError(
|
|
694
|
+
body.message || body.error_description || `Partner token request failed with status ${response.status}`,
|
|
695
|
+
body
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
const data = await response.json().catch(() => ({}));
|
|
699
|
+
if (!data.access_token || typeof data.expires_in !== "number") {
|
|
700
|
+
throw new import_connect2.EdgeAuthenticationError("Partner token response missing access_token or expires_in");
|
|
701
|
+
}
|
|
702
|
+
this.partnerTokenCache = {
|
|
703
|
+
token: data.access_token,
|
|
704
|
+
expiresAt: Date.now() + (data.expires_in - 60) * 1e3
|
|
705
|
+
};
|
|
706
|
+
return data.access_token;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* @internal Test-only: clears the cached partner token so the next call
|
|
710
|
+
* to `syncWebhookEvents` re-fetches it. Not part of the public API.
|
|
711
|
+
*/
|
|
712
|
+
_resetPartnerTokenCache() {
|
|
713
|
+
this.partnerTokenCache = null;
|
|
714
|
+
}
|
|
518
715
|
getRetryDelay(attempt) {
|
|
519
716
|
const { backoff, baseDelayMs } = this.retryConfig;
|
|
520
717
|
if (backoff === "exponential") {
|
|
@@ -624,11 +821,41 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
624
821
|
}
|
|
625
822
|
};
|
|
626
823
|
|
|
824
|
+
// src/webhook-signing.ts
|
|
825
|
+
var import_node_crypto = __toESM(require("crypto"));
|
|
826
|
+
function verifyWebhookSignature(header, body, secret, options = {}) {
|
|
827
|
+
if (typeof header !== "string" || typeof body !== "string" || typeof secret !== "string") {
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
const requestedTolerance = options.toleranceSeconds;
|
|
831
|
+
const toleranceSeconds = typeof requestedTolerance === "number" && Number.isFinite(requestedTolerance) && requestedTolerance >= 0 ? requestedTolerance : 300;
|
|
832
|
+
const parts = header.split(",").map((p) => p.trim());
|
|
833
|
+
const tPart = parts.find((p) => p.startsWith("t="));
|
|
834
|
+
const v1Part = parts.find((p) => p.startsWith("v1="));
|
|
835
|
+
if (!tPart || !v1Part) return false;
|
|
836
|
+
const timestamp = parseInt(tPart.slice(2), 10);
|
|
837
|
+
const signature = v1Part.slice(3);
|
|
838
|
+
if (isNaN(timestamp) || !signature) return false;
|
|
839
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
840
|
+
if (Math.abs(now - timestamp) > toleranceSeconds) return false;
|
|
841
|
+
const signedContent = `${timestamp}.${body}`;
|
|
842
|
+
const expectedHmac = import_node_crypto.default.createHmac("sha256", secret).update(signedContent).digest("hex");
|
|
843
|
+
try {
|
|
844
|
+
const sigBuf = Buffer.from(signature, "hex");
|
|
845
|
+
const expBuf = Buffer.from(expectedHmac, "hex");
|
|
846
|
+
if (sigBuf.length !== expBuf.length) return false;
|
|
847
|
+
return import_node_crypto.default.timingSafeEqual(sigBuf, expBuf);
|
|
848
|
+
} catch {
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
627
853
|
// src/index.ts
|
|
628
854
|
var import_connect3 = require("@edge-markets/connect");
|
|
629
855
|
var import_connect4 = require("@edge-markets/connect");
|
|
630
856
|
// Annotate the CommonJS export names for ESM import in node:
|
|
631
857
|
0 && (module.exports = {
|
|
858
|
+
EDGE_WEBHOOK_EVENT_TYPES,
|
|
632
859
|
EdgeApiError,
|
|
633
860
|
EdgeAuthenticationError,
|
|
634
861
|
EdgeConnectServer,
|
|
@@ -648,5 +875,6 @@ var import_connect4 = require("@edge-markets/connect");
|
|
|
648
875
|
isEdgeError,
|
|
649
876
|
isIdentityVerificationError,
|
|
650
877
|
isNetworkError,
|
|
651
|
-
isProductionEnvironment
|
|
878
|
+
isProductionEnvironment,
|
|
879
|
+
verifyWebhookSignature
|
|
652
880
|
});
|