@edge-markets/connect-node 1.7.0 → 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
@@ -10,7 +10,10 @@ Server SDK for EDGE Connect token exchange and API calls.
10
10
  - 🛡️ **Typed errors** - Specific error classes for each scenario
11
11
  - 📝 **TypeScript first** - Complete type definitions
12
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
13
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
14
17
 
15
18
  ## Installation
16
19
 
@@ -25,14 +28,11 @@ yarn add @edge-markets/connect-node
25
28
  ## Quick Start
26
29
 
27
30
  ```typescript
28
- import { EdgeConnectServer } from '@edge-markets/connect-node'
31
+ import { createEdgeConnectServerFromEnv } from '@edge-markets/connect-node'
29
32
 
30
- // Create instance once (reuse for all requests)
31
- const edge = new EdgeConnectServer({
32
- clientId: process.env.EDGE_CLIENT_ID!,
33
- clientSecret: process.env.EDGE_CLIENT_SECRET!,
34
- environment: 'staging',
35
- })
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()
36
36
 
37
37
  // Exchange code from EdgeLink for tokens
38
38
  const tokens = await edge.exchangeCode(code, codeVerifier)
@@ -51,6 +51,48 @@ Never expose your client secret to browsers or client-side code.
51
51
 
52
52
  ## Configuration
53
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
+
54
96
  ```typescript
55
97
  interface EdgeConnectServerConfig {
56
98
  clientId: string // Your user-facing OAuth client ID
@@ -59,6 +101,7 @@ interface EdgeConnectServerConfig {
59
101
 
60
102
  // Optional
61
103
  apiBaseUrl?: string // Custom API URL (dev only)
104
+ partnerApiBaseUrl?: string // Custom partner dashboard API URL (dev only)
62
105
  oauthBaseUrl?: string // Custom OAuth URL (dev only)
63
106
  timeout?: number // Request timeout (default: 30000ms)
64
107
  retry?: RetryConfig // Retry configuration
@@ -72,6 +115,11 @@ interface EdgeConnectServerConfig {
72
115
  partnerClientId?: string
73
116
  partnerClientSecret?: string
74
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
+
75
123
  // Optional: Message Level Encryption (Connect endpoints only)
76
124
  mle?: {
77
125
  enabled: boolean
@@ -81,6 +129,14 @@ interface EdgeConnectServerConfig {
81
129
  partnerKeyId: string // Your key ID (kid) expected in response headers
82
130
  strictResponseEncryption?: boolean // default true
83
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
+ }
84
140
  }
85
141
  ```
86
142
 
@@ -103,6 +159,34 @@ const edge = new EdgeConnectServer({
103
159
 
104
160
  When enabled, the SDK sends `X-Edge-MLE: v1`, encrypts request bodies, and decrypts encrypted Connect responses.
105
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
+
106
190
  ## Token Exchange
107
191
 
108
192
  After EdgeLink completes, exchange the code for tokens:
@@ -134,6 +218,36 @@ export async function POST(req: Request) {
134
218
  }
135
219
  ```
136
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
+
137
251
  ## Token Refresh
138
252
 
139
253
  Refresh tokens before they expire:
@@ -237,13 +351,13 @@ fail, expire, or when a user revokes consent. The SDK provides two helpers:
237
351
  **signature verification** for the live HTTP delivery channel, and a
238
352
  **reconciliation sync method** for the cron-driven safety net.
239
353
 
240
- ### Verify webhook signatures
354
+ ### Parse and verify webhook deliveries
241
355
 
242
356
  Every webhook arrives with an `X-Edge-Signature` header in `t=...,v1=...`
243
357
  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.
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.
247
361
 
248
362
  > ⚠️ **The `body` argument MUST be the raw request bytes**, not a parsed
249
363
  > object that you re-stringified. `JSON.stringify(req.body)` reorders keys
@@ -251,7 +365,7 @@ to prevent replay attacks.
251
365
  > raw body via your framework's raw-body middleware.
252
366
 
253
367
  ```typescript
254
- import { verifyWebhookSignature, type EdgeWebhookEvent } from '@edge-markets/connect-node'
368
+ import { parseAndVerifyWebhook } from '@edge-markets/connect-node'
255
369
  import express from 'express'
256
370
 
257
371
  const app = express()
@@ -261,14 +375,13 @@ app.post(
261
375
  // 👇 Critical: this gives you req.body as a Buffer, not a parsed object
262
376
  express.raw({ type: 'application/json' }),
263
377
  (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')
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
+ })
270
383
 
271
- const event: EdgeWebhookEvent = JSON.parse(req.body.toString('utf8'))
384
+ // Store eventId + signatureTimestamp in your durable inbox if you use one.
272
385
  // Process asynchronously and return 200 immediately — see "Best Practices"
273
386
  void processEdgeEvent(event)
274
387
  res.status(200).send('ok')
@@ -283,26 +396,36 @@ app.post(
283
396
  const app = await NestFactory.create(AppModule, { rawBody: true })
284
397
 
285
398
  // edge-webhook.controller.ts
286
- import { Controller, Post, Req, HttpCode, type RawBodyRequest } from '@nestjs/common'
399
+ import { Controller, Post, Req, HttpCode, UnauthorizedException, type RawBodyRequest } from '@nestjs/common'
287
400
  import type { Request } from 'express'
288
- import { verifyWebhookSignature } from '@edge-markets/connect-node'
401
+ import { EdgeAuthenticationError, parseAndVerifyWebhook } from '@edge-markets/connect-node'
289
402
 
290
403
  @Controller('webhooks')
291
404
  export class EdgeWebhookController {
292
405
  @Post('edge')
293
406
  @HttpCode(200)
294
407
  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
- // ...
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
+ }
302
421
  }
303
422
  }
304
423
  ```
305
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
+
306
429
  ### Type-safe event handling with `EdgeWebhookEvent`
307
430
 
308
431
  `EdgeWebhookEvent` is a discriminated union over `event.type`. Switching
@@ -342,7 +465,7 @@ function processEdgeEvent(event: EdgeWebhookEvent) {
342
465
  number, to preserve decimal precision — parse it with a decimal-aware
343
466
  library on your side.
344
467
 
345
- ### Reconcile missed events with `syncWebhookEvents`
468
+ ### Reconcile missed events with `createWebhookReconciler`
346
469
 
347
470
  Even with retries and a dead-letter queue, network or partner outages can
348
471
  drop webhook events. Run `syncWebhookEvents` from a cron (every ~5 minutes
@@ -354,32 +477,34 @@ This method requires the partner-level credentials (`partnerClientId` /
354
477
  handles a 401 by refreshing the token once and retrying.
355
478
 
356
479
  ```typescript
357
- import { EdgeConnectServer } from '@edge-markets/connect-node'
480
+ import { createEdgeConnectServerFromEnv } from '@edge-markets/connect-node'
358
481
 
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!,
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,
365
492
  })
366
493
 
367
494
  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
- }
495
+ const result = await reconciler.run()
496
+ logger.info('EDGE webhook reconciliation complete', result)
378
497
  }
379
498
 
380
499
  setInterval(reconcile, 5 * 60 * 1000)
381
500
  ```
382
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
+
383
508
  The server enforces a **30-day lookback**. If your cursor is older than 30
384
509
  days, you will receive events from 30 days ago — re-bootstrap from a known
385
510
  good state via `getTransfer` if you have been offline for longer.
@@ -396,6 +521,11 @@ good state via `getTransfer` if you have been offline for longer.
396
521
  4. **Run reconciliation as a backstop** — webhooks are the fast path,
397
522
  `syncWebhookEvents` is the safety net. They share the same handler.
398
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
+
399
529
  ## Error Handling
400
530
 
401
531
  ```typescript