@edge-markets/connect-node 1.6.1 → 1.8.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 +319 -11
- package/dist/index.d.mts +442 -4
- package/dist/index.d.ts +442 -4
- package/dist/index.js +1509 -62
- package/dist/index.mjs +1473 -34
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -9,6 +9,11 @@ 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 parsing + validation** - Verify raw bodies and fail closed on malformed event payloads
|
|
14
|
+
- 🔁 **Webhook reconciliation** - Cursor-based sync for catching missed events
|
|
15
|
+
- 🔐 **Token vault** - Versioned AES-256-GCM token encryption with key rotation
|
|
16
|
+
- ⚙️ **Env config helper** - Build SDK config from standard EDGE env vars
|
|
12
17
|
|
|
13
18
|
## Installation
|
|
14
19
|
|
|
@@ -23,14 +28,11 @@ yarn add @edge-markets/connect-node
|
|
|
23
28
|
## Quick Start
|
|
24
29
|
|
|
25
30
|
```typescript
|
|
26
|
-
import {
|
|
31
|
+
import { createEdgeConnectServerFromEnv } from '@edge-markets/connect-node'
|
|
27
32
|
|
|
28
|
-
// Create instance once (reuse for all requests)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
clientSecret: process.env.EDGE_CLIENT_SECRET!,
|
|
32
|
-
environment: 'staging',
|
|
33
|
-
})
|
|
33
|
+
// Create instance once (reuse for all requests). Reads EDGE_CLIENT_ID,
|
|
34
|
+
// EDGE_CLIENT_SECRET, EDGE_ENVIRONMENT, optional mTLS and partner sync env vars.
|
|
35
|
+
const { server: edge } = createEdgeConnectServerFromEnv()
|
|
34
36
|
|
|
35
37
|
// Exchange code from EdgeLink for tokens
|
|
36
38
|
const tokens = await edge.exchangeCode(code, codeVerifier)
|
|
@@ -49,20 +51,75 @@ Never expose your client secret to browsers or client-side code.
|
|
|
49
51
|
|
|
50
52
|
## Configuration
|
|
51
53
|
|
|
54
|
+
### From environment variables
|
|
55
|
+
|
|
56
|
+
Most partner backends should use `createEdgeConnectServerFromEnv()` so mTLS
|
|
57
|
+
PEM handling, partner sync credentials, URL overrides, and timeout parsing are
|
|
58
|
+
consistent across integrations.
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
import { createEdgeConnectServerFromEnv } from '@edge-markets/connect-node'
|
|
62
|
+
|
|
63
|
+
const { server: edge, capabilities, warnings } = createEdgeConnectServerFromEnv({
|
|
64
|
+
requireMtls: process.env.NODE_ENV === 'production',
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
warnings.forEach((warning) => logger.warn(warning))
|
|
68
|
+
|
|
69
|
+
if (!capabilities.webhookSyncConfigured) {
|
|
70
|
+
logger.warn('EDGE webhook sync is not configured; set EDGE_PARTNER_CLIENT_ID and EDGE_PARTNER_CLIENT_SECRET')
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Supported environment variables:
|
|
75
|
+
|
|
76
|
+
| Variable | Description |
|
|
77
|
+
|----------|-------------|
|
|
78
|
+
| `EDGE_CLIENT_ID` | User-facing OAuth client ID |
|
|
79
|
+
| `EDGE_CLIENT_SECRET` | User-facing OAuth client secret |
|
|
80
|
+
| `EDGE_ENVIRONMENT` | `production`, `staging`, `sandbox`, or `development` |
|
|
81
|
+
| `EDGE_API_BASE_URL` | Optional Connect API override |
|
|
82
|
+
| `EDGE_OAUTH_BASE_URL` | Optional OAuth/token URL override |
|
|
83
|
+
| `EDGE_PARTNER_API_BASE_URL` | Optional partner dashboard API override |
|
|
84
|
+
| `EDGE_PARTNER_CLIENT_ID` | Partner machine-to-machine client ID for webhook sync |
|
|
85
|
+
| `EDGE_PARTNER_CLIENT_SECRET` | Partner machine-to-machine client secret for webhook sync |
|
|
86
|
+
| `EDGE_MTLS_CERT` / `EDGE_MTLS_CERT_PATH` | Client certificate PEM or file path |
|
|
87
|
+
| `EDGE_MTLS_KEY` / `EDGE_MTLS_KEY_PATH` | Client private key PEM or file path |
|
|
88
|
+
| `EDGE_MTLS_CA` / `EDGE_MTLS_CA_PATH` | Optional extra server trust root PEM or file path |
|
|
89
|
+
| `EDGE_TIMEOUT_MS` | Optional request timeout in milliseconds |
|
|
90
|
+
|
|
91
|
+
Use inline PEMs with escaped newlines (`\n`) or file paths. If both are set,
|
|
92
|
+
the inline value wins and the helper returns a warning.
|
|
93
|
+
|
|
94
|
+
### Manual configuration
|
|
95
|
+
|
|
52
96
|
```typescript
|
|
53
97
|
interface EdgeConnectServerConfig {
|
|
54
|
-
clientId: string // Your OAuth client ID
|
|
55
|
-
clientSecret: string // Your OAuth client secret (keep secret!)
|
|
98
|
+
clientId: string // Your user-facing OAuth client ID
|
|
99
|
+
clientSecret: string // Your user-facing OAuth client secret (keep secret!)
|
|
56
100
|
environment: EdgeEnvironment // 'production' | 'staging' | 'sandbox'
|
|
57
|
-
|
|
101
|
+
|
|
58
102
|
// Optional
|
|
59
103
|
apiBaseUrl?: string // Custom API URL (dev only)
|
|
104
|
+
partnerApiBaseUrl?: string // Custom partner dashboard API URL (dev only)
|
|
60
105
|
oauthBaseUrl?: string // Custom OAuth URL (dev only)
|
|
61
106
|
timeout?: number // Request timeout (default: 30000ms)
|
|
62
107
|
retry?: RetryConfig // Retry configuration
|
|
63
108
|
onRequest?: (info) => void // Hook called before each request
|
|
64
109
|
onResponse?: (info) => void // Hook called after each response
|
|
65
110
|
|
|
111
|
+
// Optional: Partner-level credentials for non-user-scoped endpoints.
|
|
112
|
+
// Required ONLY when calling syncWebhookEvents — the rest of the SDK
|
|
113
|
+
// works without them. These are a DIFFERENT OAuth client than clientId
|
|
114
|
+
// (machine-to-machine, not user-facing).
|
|
115
|
+
partnerClientId?: string
|
|
116
|
+
partnerClientSecret?: string
|
|
117
|
+
|
|
118
|
+
// By default the SDK derives partnerApiBaseUrl from apiBaseUrl by replacing
|
|
119
|
+
// /connect/v1 with /v1. For example:
|
|
120
|
+
// https://connect-staging.edgeboost.io/connect/v1
|
|
121
|
+
// -> https://connect-staging.edgeboost.io/v1
|
|
122
|
+
|
|
66
123
|
// Optional: Message Level Encryption (Connect endpoints only)
|
|
67
124
|
mle?: {
|
|
68
125
|
enabled: boolean
|
|
@@ -72,6 +129,14 @@ interface EdgeConnectServerConfig {
|
|
|
72
129
|
partnerKeyId: string // Your key ID (kid) expected in response headers
|
|
73
130
|
strictResponseEncryption?: boolean // default true
|
|
74
131
|
}
|
|
132
|
+
|
|
133
|
+
// Optional: Mutual TLS client authentication
|
|
134
|
+
mtls?: {
|
|
135
|
+
enabled: boolean
|
|
136
|
+
cert: string // Your client certificate PEM
|
|
137
|
+
key: string // Your client private key PEM
|
|
138
|
+
ca?: string | string[] // Additional server trust roots, if EDGE provides them
|
|
139
|
+
}
|
|
75
140
|
}
|
|
76
141
|
```
|
|
77
142
|
|
|
@@ -94,6 +159,34 @@ const edge = new EdgeConnectServer({
|
|
|
94
159
|
|
|
95
160
|
When enabled, the SDK sends `X-Edge-MLE: v1`, encrypts request bodies, and decrypts encrypted Connect responses.
|
|
96
161
|
|
|
162
|
+
### Mutual TLS (mTLS)
|
|
163
|
+
|
|
164
|
+
Use `mtls` when EDGE has issued your partner backend a client certificate and key:
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
const edge = new EdgeConnectServer({
|
|
168
|
+
clientId: process.env.EDGE_CLIENT_ID!,
|
|
169
|
+
clientSecret: process.env.EDGE_CLIENT_SECRET!,
|
|
170
|
+
environment: 'staging',
|
|
171
|
+
mtls: {
|
|
172
|
+
enabled: true,
|
|
173
|
+
cert: process.env.EDGE_MTLS_CERT!,
|
|
174
|
+
key: process.env.EDGE_MTLS_KEY!,
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`cert` and `key` authenticate your backend to EDGE Connect. They are client
|
|
180
|
+
authentication material and must stay on your server.
|
|
181
|
+
|
|
182
|
+
`ca` is optional server trust material. Public EDGE Connect gateways such as
|
|
183
|
+
`https://connect.edgeboost.io` and `https://connect-staging.edgeboost.io` use
|
|
184
|
+
public certificates, so most partners should omit `ca`. If EDGE provides a
|
|
185
|
+
private server CA for a non-public endpoint, pass it as `ca`; the SDK appends it
|
|
186
|
+
to Node's default public trust roots instead of replacing them.
|
|
187
|
+
|
|
188
|
+
Do not disable TLS verification and do not set `NODE_TLS_REJECT_UNAUTHORIZED=0`.
|
|
189
|
+
|
|
97
190
|
## Token Exchange
|
|
98
191
|
|
|
99
192
|
After EdgeLink completes, exchange the code for tokens:
|
|
@@ -125,6 +218,36 @@ export async function POST(req: Request) {
|
|
|
125
218
|
}
|
|
126
219
|
```
|
|
127
220
|
|
|
221
|
+
### Encrypting stored tokens
|
|
222
|
+
|
|
223
|
+
Store access and refresh tokens encrypted at rest. `EdgeTokenVault` gives
|
|
224
|
+
partners a stable AES-256-GCM envelope with key IDs and key rotation support;
|
|
225
|
+
you still choose the database table and user mapping.
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
import { createEdgeTokenVault } from '@edge-markets/connect-node'
|
|
229
|
+
|
|
230
|
+
const tokenVault = createEdgeTokenVault({
|
|
231
|
+
currentKey: {
|
|
232
|
+
id: process.env.EDGE_TOKEN_ENCRYPTION_KEY_ID ?? '2026-06',
|
|
233
|
+
key: process.env.EDGE_TOKEN_ENCRYPTION_KEY!, // 32 bytes as base64, base64url, or hex
|
|
234
|
+
},
|
|
235
|
+
previousKeys: [
|
|
236
|
+
// { id: '2026-05', key: process.env.EDGE_TOKEN_ENCRYPTION_KEY_2026_05! },
|
|
237
|
+
],
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const tokens = await edge.exchangeCode(code, codeVerifier)
|
|
241
|
+
await db.edgeConnections.upsert({
|
|
242
|
+
userId,
|
|
243
|
+
encryptedTokens: tokenVault.encryptTokens(tokens),
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
const connection = await db.edgeConnections.get(userId)
|
|
247
|
+
const decrypted = tokenVault.decryptTokens(connection.encryptedTokens)
|
|
248
|
+
const client = edge.forUser(decrypted.accessToken)
|
|
249
|
+
```
|
|
250
|
+
|
|
128
251
|
## Token Refresh
|
|
129
252
|
|
|
130
253
|
Refresh tokens before they expire:
|
|
@@ -195,7 +318,10 @@ const session = await client.createVerificationSession(transfer.transferId, {
|
|
|
195
318
|
// 3. Listen for the postMessage event from the iframe (or poll
|
|
196
319
|
// getVerificationSessionStatus) to learn when verification completes.
|
|
197
320
|
// A `transfer.completed` webhook will be delivered to your configured
|
|
198
|
-
// webhook URL once the transfer settles.
|
|
321
|
+
// webhook URL once the transfer settles. For browser-crash recovery,
|
|
322
|
+
// your backend should also reconcile against
|
|
323
|
+
// GET /v1/partner/webhooks/events/sync using a dashboard
|
|
324
|
+
// client_credentials token from POST /connect/oauth/token.
|
|
199
325
|
|
|
200
326
|
// Get transfer status
|
|
201
327
|
const status = await client.getTransfer(transfer.transferId)
|
|
@@ -218,6 +344,188 @@ await client.revokeConsent()
|
|
|
218
344
|
await db.edgeConnections.delete(userId)
|
|
219
345
|
```
|
|
220
346
|
|
|
347
|
+
## Webhooks
|
|
348
|
+
|
|
349
|
+
EDGE Connect delivers webhook events to your server when transfers complete,
|
|
350
|
+
fail, expire, or when a user revokes consent. The SDK provides two helpers:
|
|
351
|
+
**signature verification** for the live HTTP delivery channel, and a
|
|
352
|
+
**reconciliation sync method** for the cron-driven safety net.
|
|
353
|
+
|
|
354
|
+
### Parse and verify webhook deliveries
|
|
355
|
+
|
|
356
|
+
Every webhook arrives with an `X-Edge-Signature` header in `t=...,v1=...`
|
|
357
|
+
format — HMAC-SHA256 over `${timestamp}.${rawBody}` keyed by your webhook
|
|
358
|
+
secret. Use `parseAndVerifyWebhook` to verify the signature, parse JSON, and
|
|
359
|
+
runtime-validate the typed event envelope in one step. The helper rejects
|
|
360
|
+
events older than 5 minutes by default to prevent replay attacks.
|
|
361
|
+
|
|
362
|
+
> ⚠️ **The `body` argument MUST be the raw request bytes**, not a parsed
|
|
363
|
+
> object that you re-stringified. `JSON.stringify(req.body)` reorders keys
|
|
364
|
+
> and silently breaks verification with no diagnostic. Always capture the
|
|
365
|
+
> raw body via your framework's raw-body middleware.
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
import { parseAndVerifyWebhook } from '@edge-markets/connect-node'
|
|
369
|
+
import express from 'express'
|
|
370
|
+
|
|
371
|
+
const app = express()
|
|
372
|
+
|
|
373
|
+
app.post(
|
|
374
|
+
'/webhooks/edge',
|
|
375
|
+
// 👇 Critical: this gives you req.body as a Buffer, not a parsed object
|
|
376
|
+
express.raw({ type: 'application/json' }),
|
|
377
|
+
(req, res) => {
|
|
378
|
+
const { event, eventId, signatureTimestamp } = parseAndVerifyWebhook({
|
|
379
|
+
rawBody: req.body,
|
|
380
|
+
signatureHeader: req.headers['x-edge-signature'],
|
|
381
|
+
secret: process.env.EDGE_WEBHOOK_SECRET!,
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
// Store eventId + signatureTimestamp in your durable inbox if you use one.
|
|
385
|
+
// Process asynchronously and return 200 immediately — see "Best Practices"
|
|
386
|
+
void processEdgeEvent(event)
|
|
387
|
+
res.status(200).send('ok')
|
|
388
|
+
},
|
|
389
|
+
)
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
#### NestJS
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
// main.ts
|
|
396
|
+
const app = await NestFactory.create(AppModule, { rawBody: true })
|
|
397
|
+
|
|
398
|
+
// edge-webhook.controller.ts
|
|
399
|
+
import { Controller, Post, Req, HttpCode, UnauthorizedException, type RawBodyRequest } from '@nestjs/common'
|
|
400
|
+
import type { Request } from 'express'
|
|
401
|
+
import { EdgeAuthenticationError, parseAndVerifyWebhook } from '@edge-markets/connect-node'
|
|
402
|
+
|
|
403
|
+
@Controller('webhooks')
|
|
404
|
+
export class EdgeWebhookController {
|
|
405
|
+
@Post('edge')
|
|
406
|
+
@HttpCode(200)
|
|
407
|
+
handle(@Req() req: RawBodyRequest<Request>) {
|
|
408
|
+
try {
|
|
409
|
+
const { event } = parseAndVerifyWebhook({
|
|
410
|
+
rawBody: req.rawBody ?? Buffer.from(''),
|
|
411
|
+
signatureHeader: req.headers['x-edge-signature'],
|
|
412
|
+
secret: process.env.EDGE_WEBHOOK_SECRET!,
|
|
413
|
+
})
|
|
414
|
+
void processEdgeEvent(event)
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (error instanceof EdgeAuthenticationError) {
|
|
417
|
+
throw new UnauthorizedException('invalid signature')
|
|
418
|
+
}
|
|
419
|
+
throw error
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
`verifyWebhookSignature` remains exported for advanced integrations, but
|
|
426
|
+
`parseAndVerifyWebhook` is the recommended default because it fails closed on
|
|
427
|
+
unknown event types, malformed payloads, and parsed-body mistakes.
|
|
428
|
+
|
|
429
|
+
### Type-safe event handling with `EdgeWebhookEvent`
|
|
430
|
+
|
|
431
|
+
`EdgeWebhookEvent` is a discriminated union over `event.type`. Switching
|
|
432
|
+
narrows `event.data` automatically — no casts required.
|
|
433
|
+
|
|
434
|
+
```typescript
|
|
435
|
+
import type { EdgeWebhookEvent } from '@edge-markets/connect-node'
|
|
436
|
+
|
|
437
|
+
function processEdgeEvent(event: EdgeWebhookEvent) {
|
|
438
|
+
switch (event.type) {
|
|
439
|
+
case 'transfer.completed':
|
|
440
|
+
// event.data is { transferId, status: 'completed', type, amount }
|
|
441
|
+
creditWallet(event.data.transferId, event.data.amount)
|
|
442
|
+
break
|
|
443
|
+
case 'transfer.failed':
|
|
444
|
+
// event.data.reason is `string | undefined`
|
|
445
|
+
logFailure(event.data.transferId, event.data.reason ?? 'unknown')
|
|
446
|
+
break
|
|
447
|
+
case 'transfer.expired':
|
|
448
|
+
cancelPending(event.data.transferId)
|
|
449
|
+
break
|
|
450
|
+
case 'transfer.processing':
|
|
451
|
+
// @experimental — reserved for ledger dual-write hand-off
|
|
452
|
+
break
|
|
453
|
+
case 'consent.revoked':
|
|
454
|
+
deactivateLink(event.data.userId, event.data.clientId)
|
|
455
|
+
break
|
|
456
|
+
default: {
|
|
457
|
+
const _exhaustive: never = event
|
|
458
|
+
return _exhaustive
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
`event.data.amount` is intentionally a `string` (e.g. `'100.00'`), not a
|
|
465
|
+
number, to preserve decimal precision — parse it with a decimal-aware
|
|
466
|
+
library on your side.
|
|
467
|
+
|
|
468
|
+
### Reconcile missed events with `createWebhookReconciler`
|
|
469
|
+
|
|
470
|
+
Even with retries and a dead-letter queue, network or partner outages can
|
|
471
|
+
drop webhook events. Run `syncWebhookEvents` from a cron (every ~5 minutes
|
|
472
|
+
is typical) to catch anything your primary receiver missed.
|
|
473
|
+
|
|
474
|
+
This method requires the partner-level credentials (`partnerClientId` /
|
|
475
|
+
`partnerClientSecret`) on `EdgeConnectServer`. It uses the OAuth
|
|
476
|
+
`client_credentials` grant, caches the partner token internally, and
|
|
477
|
+
handles a 401 by refreshing the token once and retrying.
|
|
478
|
+
|
|
479
|
+
```typescript
|
|
480
|
+
import { createEdgeConnectServerFromEnv } from '@edge-markets/connect-node'
|
|
481
|
+
|
|
482
|
+
const { server: edge } = createEdgeConnectServerFromEnv()
|
|
483
|
+
|
|
484
|
+
const reconciler = edge.createWebhookReconciler({
|
|
485
|
+
cursorStore: {
|
|
486
|
+
load: () => db.cursors.get('edge_webhook_sync'),
|
|
487
|
+
save: (cursor) => db.cursors.set('edge_webhook_sync', cursor),
|
|
488
|
+
},
|
|
489
|
+
processEvent: processEdgeEvent, // same idempotent handler as live webhooks
|
|
490
|
+
limit: 100,
|
|
491
|
+
maxPages: 10,
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
async function reconcile() {
|
|
495
|
+
const result = await reconciler.run()
|
|
496
|
+
logger.info('EDGE webhook reconciliation complete', result)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
setInterval(reconcile, 5 * 60 * 1000)
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
The reconciler saves the cursor only after `processEvent(event)` succeeds and
|
|
503
|
+
skips overlapping runs on the same instance. You provide cursor storage and
|
|
504
|
+
the idempotent event handler; the SDK owns pagination and cursor advancement.
|
|
505
|
+
|
|
506
|
+
If you need full control, `edge.syncWebhookEvents()` remains available.
|
|
507
|
+
|
|
508
|
+
The server enforces a **30-day lookback**. If your cursor is older than 30
|
|
509
|
+
days, you will receive events from 30 days ago — re-bootstrap from a known
|
|
510
|
+
good state via `getTransfer` if you have been offline for longer.
|
|
511
|
+
|
|
512
|
+
### Best practices
|
|
513
|
+
|
|
514
|
+
1. **Verify signatures first** — reject mis-signed events with 401 before
|
|
515
|
+
doing any work.
|
|
516
|
+
2. **Return 200 immediately** — process events asynchronously. EDGE retries
|
|
517
|
+
on non-2xx and on responses slower than 10s.
|
|
518
|
+
3. **Be idempotent** — the same event may be delivered more than once
|
|
519
|
+
(at-least-once delivery). Key your wallet credit / status update on
|
|
520
|
+
`event.id` or `event.data.transferId`.
|
|
521
|
+
4. **Run reconciliation as a backstop** — webhooks are the fast path,
|
|
522
|
+
`syncWebhookEvents` is the safety net. They share the same handler.
|
|
523
|
+
|
|
524
|
+
`syncWebhookEvents` uses the same SDK mTLS transport as user-scoped API
|
|
525
|
+
calls, fetches and caches the partner `client_credentials` token internally,
|
|
526
|
+
retries transient sync failures with the SDK retry policy, and redacts token
|
|
527
|
+
material from observability hooks.
|
|
528
|
+
|
|
221
529
|
## Error Handling
|
|
222
530
|
|
|
223
531
|
```typescript
|