@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 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 { EdgeConnectServer } from '@edge-markets/connect-node'
31
+ import { createEdgeConnectServerFromEnv } from '@edge-markets/connect-node'
27
32
 
28
- // Create instance once (reuse for all requests)
29
- const edge = new EdgeConnectServer({
30
- clientId: process.env.EDGE_CLIENT_ID!,
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