@edge-markets/connect-node 1.6.1 → 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 +182 -4
- package/dist/index.d.mts +179 -3
- package/dist/index.d.ts +179 -3
- package/dist/index.js +227 -13
- package/dist/index.mjs +230 -13
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -9,6 +9,8 @@ Server SDK for EDGE Connect token exchange and API calls.
|
|
|
9
9
|
- 📡 **Full API client** - User, balance, transfers via `forUser()` pattern
|
|
10
10
|
- 🛡️ **Typed errors** - Specific error classes for each scenario
|
|
11
11
|
- 📝 **TypeScript first** - Complete type definitions
|
|
12
|
+
- 🪝 **Webhook signature verification** - Constant-time HMAC-SHA256 with replay protection
|
|
13
|
+
- 🔁 **Webhook reconciliation** - Cursor-based sync for catching missed events
|
|
12
14
|
|
|
13
15
|
## Installation
|
|
14
16
|
|
|
@@ -51,10 +53,10 @@ Never expose your client secret to browsers or client-side code.
|
|
|
51
53
|
|
|
52
54
|
```typescript
|
|
53
55
|
interface EdgeConnectServerConfig {
|
|
54
|
-
clientId: string // Your OAuth client ID
|
|
55
|
-
clientSecret: string // Your OAuth client secret (keep secret!)
|
|
56
|
+
clientId: string // Your user-facing OAuth client ID
|
|
57
|
+
clientSecret: string // Your user-facing OAuth client secret (keep secret!)
|
|
56
58
|
environment: EdgeEnvironment // 'production' | 'staging' | 'sandbox'
|
|
57
|
-
|
|
59
|
+
|
|
58
60
|
// Optional
|
|
59
61
|
apiBaseUrl?: string // Custom API URL (dev only)
|
|
60
62
|
oauthBaseUrl?: string // Custom OAuth URL (dev only)
|
|
@@ -63,6 +65,13 @@ interface EdgeConnectServerConfig {
|
|
|
63
65
|
onRequest?: (info) => void // Hook called before each request
|
|
64
66
|
onResponse?: (info) => void // Hook called after each response
|
|
65
67
|
|
|
68
|
+
// Optional: Partner-level credentials for non-user-scoped endpoints.
|
|
69
|
+
// Required ONLY when calling syncWebhookEvents — the rest of the SDK
|
|
70
|
+
// works without them. These are a DIFFERENT OAuth client than clientId
|
|
71
|
+
// (machine-to-machine, not user-facing).
|
|
72
|
+
partnerClientId?: string
|
|
73
|
+
partnerClientSecret?: string
|
|
74
|
+
|
|
66
75
|
// Optional: Message Level Encryption (Connect endpoints only)
|
|
67
76
|
mle?: {
|
|
68
77
|
enabled: boolean
|
|
@@ -195,7 +204,10 @@ const session = await client.createVerificationSession(transfer.transferId, {
|
|
|
195
204
|
// 3. Listen for the postMessage event from the iframe (or poll
|
|
196
205
|
// getVerificationSessionStatus) to learn when verification completes.
|
|
197
206
|
// A `transfer.completed` webhook will be delivered to your configured
|
|
198
|
-
// webhook URL once the transfer settles.
|
|
207
|
+
// webhook URL once the transfer settles. For browser-crash recovery,
|
|
208
|
+
// your backend should also reconcile against
|
|
209
|
+
// GET /v1/partner/webhooks/events/sync using a dashboard
|
|
210
|
+
// client_credentials token from POST /connect/oauth/token.
|
|
199
211
|
|
|
200
212
|
// Get transfer status
|
|
201
213
|
const status = await client.getTransfer(transfer.transferId)
|
|
@@ -218,6 +230,172 @@ await client.revokeConsent()
|
|
|
218
230
|
await db.edgeConnections.delete(userId)
|
|
219
231
|
```
|
|
220
232
|
|
|
233
|
+
## Webhooks
|
|
234
|
+
|
|
235
|
+
EDGE Connect delivers webhook events to your server when transfers complete,
|
|
236
|
+
fail, expire, or when a user revokes consent. The SDK provides two helpers:
|
|
237
|
+
**signature verification** for the live HTTP delivery channel, and a
|
|
238
|
+
**reconciliation sync method** for the cron-driven safety net.
|
|
239
|
+
|
|
240
|
+
### Verify webhook signatures
|
|
241
|
+
|
|
242
|
+
Every webhook arrives with an `X-Edge-Signature` header in `t=...,v1=...`
|
|
243
|
+
format — HMAC-SHA256 over `${timestamp}.${rawBody}` keyed by your webhook
|
|
244
|
+
secret. Use `verifyWebhookSignature` to check it. The function does
|
|
245
|
+
constant-time comparison and rejects events older than 5 minutes by default
|
|
246
|
+
to prevent replay attacks.
|
|
247
|
+
|
|
248
|
+
> ⚠️ **The `body` argument MUST be the raw request bytes**, not a parsed
|
|
249
|
+
> object that you re-stringified. `JSON.stringify(req.body)` reorders keys
|
|
250
|
+
> and silently breaks verification with no diagnostic. Always capture the
|
|
251
|
+
> raw body via your framework's raw-body middleware.
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
import { verifyWebhookSignature, type EdgeWebhookEvent } from '@edge-markets/connect-node'
|
|
255
|
+
import express from 'express'
|
|
256
|
+
|
|
257
|
+
const app = express()
|
|
258
|
+
|
|
259
|
+
app.post(
|
|
260
|
+
'/webhooks/edge',
|
|
261
|
+
// 👇 Critical: this gives you req.body as a Buffer, not a parsed object
|
|
262
|
+
express.raw({ type: 'application/json' }),
|
|
263
|
+
(req, res) => {
|
|
264
|
+
const ok = verifyWebhookSignature(
|
|
265
|
+
req.headers['x-edge-signature'] as string,
|
|
266
|
+
req.body.toString('utf8'),
|
|
267
|
+
process.env.EDGE_WEBHOOK_SECRET!,
|
|
268
|
+
)
|
|
269
|
+
if (!ok) return res.status(401).send('invalid signature')
|
|
270
|
+
|
|
271
|
+
const event: EdgeWebhookEvent = JSON.parse(req.body.toString('utf8'))
|
|
272
|
+
// Process asynchronously and return 200 immediately — see "Best Practices"
|
|
273
|
+
void processEdgeEvent(event)
|
|
274
|
+
res.status(200).send('ok')
|
|
275
|
+
},
|
|
276
|
+
)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
#### NestJS
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
// main.ts
|
|
283
|
+
const app = await NestFactory.create(AppModule, { rawBody: true })
|
|
284
|
+
|
|
285
|
+
// edge-webhook.controller.ts
|
|
286
|
+
import { Controller, Post, Req, HttpCode, type RawBodyRequest } from '@nestjs/common'
|
|
287
|
+
import type { Request } from 'express'
|
|
288
|
+
import { verifyWebhookSignature } from '@edge-markets/connect-node'
|
|
289
|
+
|
|
290
|
+
@Controller('webhooks')
|
|
291
|
+
export class EdgeWebhookController {
|
|
292
|
+
@Post('edge')
|
|
293
|
+
@HttpCode(200)
|
|
294
|
+
handle(@Req() req: RawBodyRequest<Request>) {
|
|
295
|
+
const ok = verifyWebhookSignature(
|
|
296
|
+
req.headers['x-edge-signature'] as string,
|
|
297
|
+
req.rawBody?.toString('utf8') ?? '',
|
|
298
|
+
process.env.EDGE_WEBHOOK_SECRET!,
|
|
299
|
+
)
|
|
300
|
+
if (!ok) throw new UnauthorizedException('invalid signature')
|
|
301
|
+
// ...
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Type-safe event handling with `EdgeWebhookEvent`
|
|
307
|
+
|
|
308
|
+
`EdgeWebhookEvent` is a discriminated union over `event.type`. Switching
|
|
309
|
+
narrows `event.data` automatically — no casts required.
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
import type { EdgeWebhookEvent } from '@edge-markets/connect-node'
|
|
313
|
+
|
|
314
|
+
function processEdgeEvent(event: EdgeWebhookEvent) {
|
|
315
|
+
switch (event.type) {
|
|
316
|
+
case 'transfer.completed':
|
|
317
|
+
// event.data is { transferId, status: 'completed', type, amount }
|
|
318
|
+
creditWallet(event.data.transferId, event.data.amount)
|
|
319
|
+
break
|
|
320
|
+
case 'transfer.failed':
|
|
321
|
+
// event.data.reason is `string | undefined`
|
|
322
|
+
logFailure(event.data.transferId, event.data.reason ?? 'unknown')
|
|
323
|
+
break
|
|
324
|
+
case 'transfer.expired':
|
|
325
|
+
cancelPending(event.data.transferId)
|
|
326
|
+
break
|
|
327
|
+
case 'transfer.processing':
|
|
328
|
+
// @experimental — reserved for ledger dual-write hand-off
|
|
329
|
+
break
|
|
330
|
+
case 'consent.revoked':
|
|
331
|
+
deactivateLink(event.data.userId, event.data.clientId)
|
|
332
|
+
break
|
|
333
|
+
default: {
|
|
334
|
+
const _exhaustive: never = event
|
|
335
|
+
return _exhaustive
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
`event.data.amount` is intentionally a `string` (e.g. `'100.00'`), not a
|
|
342
|
+
number, to preserve decimal precision — parse it with a decimal-aware
|
|
343
|
+
library on your side.
|
|
344
|
+
|
|
345
|
+
### Reconcile missed events with `syncWebhookEvents`
|
|
346
|
+
|
|
347
|
+
Even with retries and a dead-letter queue, network or partner outages can
|
|
348
|
+
drop webhook events. Run `syncWebhookEvents` from a cron (every ~5 minutes
|
|
349
|
+
is typical) to catch anything your primary receiver missed.
|
|
350
|
+
|
|
351
|
+
This method requires the partner-level credentials (`partnerClientId` /
|
|
352
|
+
`partnerClientSecret`) on `EdgeConnectServer`. It uses the OAuth
|
|
353
|
+
`client_credentials` grant, caches the partner token internally, and
|
|
354
|
+
handles a 401 by refreshing the token once and retrying.
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
import { EdgeConnectServer } from '@edge-markets/connect-node'
|
|
358
|
+
|
|
359
|
+
const edge = new EdgeConnectServer({
|
|
360
|
+
clientId: process.env.EDGE_CLIENT_ID!,
|
|
361
|
+
clientSecret: process.env.EDGE_CLIENT_SECRET!,
|
|
362
|
+
environment: 'staging',
|
|
363
|
+
partnerClientId: process.env.EDGE_PARTNER_CLIENT_ID!,
|
|
364
|
+
partnerClientSecret: process.env.EDGE_PARTNER_CLIENT_SECRET!,
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
async function reconcile() {
|
|
368
|
+
let cursor = await db.cursors.get('edge') // your stored watermark
|
|
369
|
+
while (true) {
|
|
370
|
+
const { events, hasMore } = await edge.syncWebhookEvents({ afterEventId: cursor })
|
|
371
|
+
for (const event of events) {
|
|
372
|
+
await processEdgeEvent(event) // same idempotent handler as live webhooks
|
|
373
|
+
cursor = event.id
|
|
374
|
+
}
|
|
375
|
+
if (events.length > 0) await db.cursors.set('edge', cursor!)
|
|
376
|
+
if (!hasMore) break
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
setInterval(reconcile, 5 * 60 * 1000)
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
The server enforces a **30-day lookback**. If your cursor is older than 30
|
|
384
|
+
days, you will receive events from 30 days ago — re-bootstrap from a known
|
|
385
|
+
good state via `getTransfer` if you have been offline for longer.
|
|
386
|
+
|
|
387
|
+
### Best practices
|
|
388
|
+
|
|
389
|
+
1. **Verify signatures first** — reject mis-signed events with 401 before
|
|
390
|
+
doing any work.
|
|
391
|
+
2. **Return 200 immediately** — process events asynchronously. EDGE retries
|
|
392
|
+
on non-2xx and on responses slower than 10s.
|
|
393
|
+
3. **Be idempotent** — the same event may be delivered more than once
|
|
394
|
+
(at-least-once delivery). Key your wallet credit / status update on
|
|
395
|
+
`event.id` or `event.data.transferId`.
|
|
396
|
+
4. **Run reconciliation as a backstop** — webhooks are the fast path,
|
|
397
|
+
`syncWebhookEvents` is the safety net. They share the same handler.
|
|
398
|
+
|
|
221
399
|
## Error Handling
|
|
222
400
|
|
|
223
401
|
```typescript
|
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as node_https from 'node:https';
|
|
2
|
-
import { EdgeEnvironment, TransferCategory, User, VerifyIdentityOptions, VerifyIdentityResult, Balance, Transfer, ListTransfersParams, TransferList, CreateVerificationSessionRequest, VerificationSession, VerificationSessionStatusResponse, EdgeTokens } from '@edge-markets/connect';
|
|
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;
|
|
@@ -89,6 +107,38 @@ declare class EdgeUserClient {
|
|
|
89
107
|
getVerificationSessionStatus(transferId: string, sessionId: string): Promise<VerificationSessionStatusResponse>;
|
|
90
108
|
}
|
|
91
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
|
+
}
|
|
92
142
|
declare class EdgeConnectServer {
|
|
93
143
|
private readonly config;
|
|
94
144
|
private readonly apiBaseUrl;
|
|
@@ -97,6 +147,7 @@ declare class EdgeConnectServer {
|
|
|
97
147
|
private readonly retryConfig;
|
|
98
148
|
private readonly httpsAgent;
|
|
99
149
|
private readonly dispatcher;
|
|
150
|
+
private partnerTokenCache;
|
|
100
151
|
static getInstance(config: EdgeConnectServerConfig): EdgeConnectServer;
|
|
101
152
|
static clearInstances(): void;
|
|
102
153
|
constructor(config: EdgeConnectServerConfig);
|
|
@@ -116,6 +167,67 @@ declare class EdgeConnectServer {
|
|
|
116
167
|
refreshTokens(refreshToken: string): Promise<EdgeTokens>;
|
|
117
168
|
/** @internal Called by {@link EdgeUserClient} — not part of the public API. */
|
|
118
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;
|
|
119
231
|
private getRetryDelay;
|
|
120
232
|
private sleep;
|
|
121
233
|
private fetchWithTimeout;
|
|
@@ -124,4 +236,68 @@ declare class EdgeConnectServer {
|
|
|
124
236
|
private handleApiErrorFromBody;
|
|
125
237
|
}
|
|
126
238
|
|
|
127
|
-
|
|
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.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as node_https from 'node:https';
|
|
2
|
-
import { EdgeEnvironment, TransferCategory, User, VerifyIdentityOptions, VerifyIdentityResult, Balance, Transfer, ListTransfersParams, TransferList, CreateVerificationSessionRequest, VerificationSession, VerificationSessionStatusResponse, EdgeTokens } from '@edge-markets/connect';
|
|
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;
|
|
@@ -89,6 +107,38 @@ declare class EdgeUserClient {
|
|
|
89
107
|
getVerificationSessionStatus(transferId: string, sessionId: string): Promise<VerificationSessionStatusResponse>;
|
|
90
108
|
}
|
|
91
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
|
+
}
|
|
92
142
|
declare class EdgeConnectServer {
|
|
93
143
|
private readonly config;
|
|
94
144
|
private readonly apiBaseUrl;
|
|
@@ -97,6 +147,7 @@ declare class EdgeConnectServer {
|
|
|
97
147
|
private readonly retryConfig;
|
|
98
148
|
private readonly httpsAgent;
|
|
99
149
|
private readonly dispatcher;
|
|
150
|
+
private partnerTokenCache;
|
|
100
151
|
static getInstance(config: EdgeConnectServerConfig): EdgeConnectServer;
|
|
101
152
|
static clearInstances(): void;
|
|
102
153
|
constructor(config: EdgeConnectServerConfig);
|
|
@@ -116,6 +167,67 @@ declare class EdgeConnectServer {
|
|
|
116
167
|
refreshTokens(refreshToken: string): Promise<EdgeTokens>;
|
|
117
168
|
/** @internal Called by {@link EdgeUserClient} — not part of the public API. */
|
|
118
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;
|
|
119
231
|
private getRetryDelay;
|
|
120
232
|
private sleep;
|
|
121
233
|
private fetchWithTimeout;
|
|
@@ -124,4 +236,68 @@ declare class EdgeConnectServer {
|
|
|
124
236
|
private handleApiErrorFromBody;
|
|
125
237
|
}
|
|
126
238
|
|
|
127
|
-
|
|
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() : "";
|
|
@@ -135,6 +145,7 @@ var EdgeUserClient = class {
|
|
|
135
145
|
return this.server._apiRequest("POST", "/transfer", this.accessToken, body);
|
|
136
146
|
}
|
|
137
147
|
async getTransfer(transferId) {
|
|
148
|
+
validateTransferId(transferId);
|
|
138
149
|
return this.server._apiRequest("GET", `/transfer/${encodeURIComponent(transferId)}`, this.accessToken);
|
|
139
150
|
}
|
|
140
151
|
async listTransfers(params) {
|
|
@@ -155,6 +166,7 @@ var EdgeUserClient = class {
|
|
|
155
166
|
* The handoff token is single-use and expires in 120 seconds.
|
|
156
167
|
*/
|
|
157
168
|
async createVerificationSession(transferId, options) {
|
|
169
|
+
validateTransferId(transferId);
|
|
158
170
|
return this.server._apiRequest(
|
|
159
171
|
"POST",
|
|
160
172
|
`/transfer/${encodeURIComponent(transferId)}/verification-session`,
|
|
@@ -167,6 +179,7 @@ var EdgeUserClient = class {
|
|
|
167
179
|
* Use as an alternative to postMessage events.
|
|
168
180
|
*/
|
|
169
181
|
async getVerificationSessionStatus(transferId, sessionId) {
|
|
182
|
+
validateTransferId(transferId);
|
|
170
183
|
return this.server._apiRequest(
|
|
171
184
|
"GET",
|
|
172
185
|
`/transfer/${encodeURIComponent(transferId)}/verification-session/${encodeURIComponent(sessionId)}`,
|
|
@@ -319,18 +332,8 @@ function environmentRequiresMtls(environment, apiBaseUrl) {
|
|
|
319
332
|
}
|
|
320
333
|
}
|
|
321
334
|
var EdgeConnectServer = class _EdgeConnectServer {
|
|
322
|
-
static getInstance(config) {
|
|
323
|
-
const key = getInstanceKey(config);
|
|
324
|
-
const existing = instances.get(key);
|
|
325
|
-
if (existing) return existing;
|
|
326
|
-
const instance = new _EdgeConnectServer(config);
|
|
327
|
-
instances.set(key, instance);
|
|
328
|
-
return instance;
|
|
329
|
-
}
|
|
330
|
-
static clearInstances() {
|
|
331
|
-
instances.clear();
|
|
332
|
-
}
|
|
333
335
|
constructor(config) {
|
|
336
|
+
this.partnerTokenCache = null;
|
|
334
337
|
if (!config.clientId) {
|
|
335
338
|
throw new Error("EdgeConnectServer: clientId is required");
|
|
336
339
|
}
|
|
@@ -359,6 +362,17 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
359
362
|
);
|
|
360
363
|
}
|
|
361
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
|
+
}
|
|
362
376
|
/**
|
|
363
377
|
* Returns the `node:https.Agent` configured with mTLS client certificates.
|
|
364
378
|
* Use this with axios, got, or other HTTP clients. Returns `undefined` if mTLS is not configured.
|
|
@@ -529,6 +543,175 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
529
543
|
lastError
|
|
530
544
|
);
|
|
531
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
|
+
}
|
|
532
715
|
getRetryDelay(attempt) {
|
|
533
716
|
const { backoff, baseDelayMs } = this.retryConfig;
|
|
534
717
|
if (backoff === "exponential") {
|
|
@@ -638,11 +821,41 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
638
821
|
}
|
|
639
822
|
};
|
|
640
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
|
+
|
|
641
853
|
// src/index.ts
|
|
642
854
|
var import_connect3 = require("@edge-markets/connect");
|
|
643
855
|
var import_connect4 = require("@edge-markets/connect");
|
|
644
856
|
// Annotate the CommonJS export names for ESM import in node:
|
|
645
857
|
0 && (module.exports = {
|
|
858
|
+
EDGE_WEBHOOK_EVENT_TYPES,
|
|
646
859
|
EdgeApiError,
|
|
647
860
|
EdgeAuthenticationError,
|
|
648
861
|
EdgeConnectServer,
|
|
@@ -662,5 +875,6 @@ var import_connect4 = require("@edge-markets/connect");
|
|
|
662
875
|
isEdgeError,
|
|
663
876
|
isIdentityVerificationError,
|
|
664
877
|
isNetworkError,
|
|
665
|
-
isProductionEnvironment
|
|
878
|
+
isProductionEnvironment,
|
|
879
|
+
verifyWebhookSignature
|
|
666
880
|
});
|
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() : "";
|
|
@@ -98,6 +106,7 @@ var EdgeUserClient = class {
|
|
|
98
106
|
return this.server._apiRequest("POST", "/transfer", this.accessToken, body);
|
|
99
107
|
}
|
|
100
108
|
async getTransfer(transferId) {
|
|
109
|
+
validateTransferId(transferId);
|
|
101
110
|
return this.server._apiRequest("GET", `/transfer/${encodeURIComponent(transferId)}`, this.accessToken);
|
|
102
111
|
}
|
|
103
112
|
async listTransfers(params) {
|
|
@@ -118,6 +127,7 @@ var EdgeUserClient = class {
|
|
|
118
127
|
* The handoff token is single-use and expires in 120 seconds.
|
|
119
128
|
*/
|
|
120
129
|
async createVerificationSession(transferId, options) {
|
|
130
|
+
validateTransferId(transferId);
|
|
121
131
|
return this.server._apiRequest(
|
|
122
132
|
"POST",
|
|
123
133
|
`/transfer/${encodeURIComponent(transferId)}/verification-session`,
|
|
@@ -130,6 +140,7 @@ var EdgeUserClient = class {
|
|
|
130
140
|
* Use as an alternative to postMessage events.
|
|
131
141
|
*/
|
|
132
142
|
async getVerificationSessionStatus(transferId, sessionId) {
|
|
143
|
+
validateTransferId(transferId);
|
|
133
144
|
return this.server._apiRequest(
|
|
134
145
|
"GET",
|
|
135
146
|
`/transfer/${encodeURIComponent(transferId)}/verification-session/${encodeURIComponent(sessionId)}`,
|
|
@@ -282,18 +293,8 @@ function environmentRequiresMtls(environment, apiBaseUrl) {
|
|
|
282
293
|
}
|
|
283
294
|
}
|
|
284
295
|
var EdgeConnectServer = class _EdgeConnectServer {
|
|
285
|
-
static getInstance(config) {
|
|
286
|
-
const key = getInstanceKey(config);
|
|
287
|
-
const existing = instances.get(key);
|
|
288
|
-
if (existing) return existing;
|
|
289
|
-
const instance = new _EdgeConnectServer(config);
|
|
290
|
-
instances.set(key, instance);
|
|
291
|
-
return instance;
|
|
292
|
-
}
|
|
293
|
-
static clearInstances() {
|
|
294
|
-
instances.clear();
|
|
295
|
-
}
|
|
296
296
|
constructor(config) {
|
|
297
|
+
this.partnerTokenCache = null;
|
|
297
298
|
if (!config.clientId) {
|
|
298
299
|
throw new Error("EdgeConnectServer: clientId is required");
|
|
299
300
|
}
|
|
@@ -322,6 +323,17 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
322
323
|
);
|
|
323
324
|
}
|
|
324
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
|
+
}
|
|
325
337
|
/**
|
|
326
338
|
* Returns the `node:https.Agent` configured with mTLS client certificates.
|
|
327
339
|
* Use this with axios, got, or other HTTP clients. Returns `undefined` if mTLS is not configured.
|
|
@@ -492,6 +504,175 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
492
504
|
lastError
|
|
493
505
|
);
|
|
494
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
|
+
}
|
|
495
676
|
getRetryDelay(attempt) {
|
|
496
677
|
const { backoff, baseDelayMs } = this.retryConfig;
|
|
497
678
|
if (backoff === "exponential") {
|
|
@@ -601,6 +782,35 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
601
782
|
}
|
|
602
783
|
};
|
|
603
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
|
+
|
|
604
814
|
// src/index.ts
|
|
605
815
|
import {
|
|
606
816
|
EdgeApiError as EdgeApiError2,
|
|
@@ -619,8 +829,14 @@ import {
|
|
|
619
829
|
isIdentityVerificationError,
|
|
620
830
|
isNetworkError
|
|
621
831
|
} from "@edge-markets/connect";
|
|
622
|
-
import {
|
|
832
|
+
import {
|
|
833
|
+
EDGE_WEBHOOK_EVENT_TYPES,
|
|
834
|
+
TRANSFER_CATEGORIES,
|
|
835
|
+
getEnvironmentConfig as getEnvironmentConfig2,
|
|
836
|
+
isProductionEnvironment
|
|
837
|
+
} from "@edge-markets/connect";
|
|
623
838
|
export {
|
|
839
|
+
EDGE_WEBHOOK_EVENT_TYPES,
|
|
624
840
|
EdgeApiError2 as EdgeApiError,
|
|
625
841
|
EdgeAuthenticationError2 as EdgeAuthenticationError,
|
|
626
842
|
EdgeConnectServer,
|
|
@@ -640,5 +856,6 @@ export {
|
|
|
640
856
|
isEdgeError,
|
|
641
857
|
isIdentityVerificationError,
|
|
642
858
|
isNetworkError,
|
|
643
|
-
isProductionEnvironment
|
|
859
|
+
isProductionEnvironment,
|
|
860
|
+
verifyWebhookSignature
|
|
644
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",
|